Skip to content

Promote dev to master: Mail app, Browser redesign, optional Store apps, agent screenshot, canvas + restart fixes#952

Merged
jaylfc merged 41 commits into
masterfrom
dev
Jun 16, 2026
Merged

Promote dev to master: Mail app, Browser redesign, optional Store apps, agent screenshot, canvas + restart fixes#952
jaylfc merged 41 commits into
masterfrom
dev

Conversation

@jaylfc

@jaylfc jaylfc commented Jun 16, 2026

Copy link
Copy Markdown
Owner

Promotes dev to master (40 commits since #938). No new release version bump in this PR; cuts the next beta state.

Highlights

Notes

All merged via per-PR review (Gitar approved, CI green) under the severity gate.

Summary by CodeRabbit

Release Notes

  • New Features

    • Mail app: Multi-account email client with compose, send, and folder navigation.
    • Optional apps: Install/uninstall additional apps (Reddit, YouTube, GitHub, X).
    • Browser mode toggle: Switch between proxy and streamed browser modes per tab.
    • Screen capture: Capture desktop screenshots on demand.
    • Theme color-scheme: Browser pages respect system theme settings.
    • Projects workspace: New split-pane workspace with messaging and canvas preview.
  • Enhancements

    • Notification system: Server-synced notifications with freshness control.
    • Improved UI: Browser toolbar, profile switcher, and tab strip refinements.
    • Mobile: Better layout and navigation across apps.
  • Bug Fixes

    • Canvas validation crash prevention and error handling.
    • Security header improvements and loopback auth support.

jaylfc added 30 commits June 15, 2026 15:57
…n feed (#939)

* feat(notifications): wire desktop bell to backend notification feed

The desktop notification bell read only from a client-only zustand store,
so the persistent backend feed (worker join/leave, backend up/down,
training, app install, disk quota) never reached the UI.

Add server-notifications lib (fetch/map/mark-read against
/api/notifications), a mergeServerNotifications store action that upserts
server rows while preserving local read state and keeping client items,
a polling hook (30s + on-open refresh) mounted in App for both shells,
and wire mark-read / mark-all-read to also persist on the backend.

Server rows are mapped to the frontend shape with an srv- id prefix and
seconds-to-millis timestamps; unknown levels fall back to info. Fetch
failures yield an empty list and never throw.

* fix(notifications): keep dismissed server items dismissed; pause poll when tab hidden

Address gitar review on #939:
- dismiss()/clearAll() record server ids so the next poll does not resurrect
  them (the merge filters dismissedServerIds).
- pause the 30s poll while the tab is backgrounded; resync on return.
)

Map backend event sources to a target app (and optional launch prop) so
clicking a notification in the centre opens the right place:

- system.update / system.lifecycle -> Settings (updates)
- disk_quota -> Settings (storage)
- worker.* / backend.* -> Cluster
- training.* -> Agents
- app.installed / app.failed -> Store

NotificationCentre opens the target via openWindow (passing meta as
launch props), marks the item read, and closes. Action-less items keep
the existing mark-read-in-place behaviour. SettingsApp accepts an
optional section launch prop, mirroring the FilesApp rootPath mechanism.
…h prop)

Double-clicking a Desktop folder passed {location,path} but FilesApp only read
rootPath, so it opened at the workspace root. FilesApp now takes a path prop that
seeds currentPath + shows the browser pane; DesktopIcons passes the folder path.
Phase 1 of the approved Projects redesign:

- Project-list sidebar (248px) restyled to the mock with project marks,
  token-driven surfaces, and the empty state preserved.
- Project header with a static-but-real presence row: avatars of the owner
  plus the real member roster (agents vs humans), derived from existing
  member/agent data. Live multiplayer presence is deferred to phase 2.
- New 8-tab bar (Workspace default) restyled with the token underline chrome.
- New Workspace hero tab: a split pane. Left reuses the project-scoped
  MessagesApp (real thread + composer + A2A bus). Right is a preview pane with
  a Preview | Code | Canvas segmented toggle and a toolbar; Canvas embeds the
  real project canvas, Preview/Code show honest placeholders (a true streamed
  live build preview is deferred to phase 2/3, marked with TODOs).
- All other tabs keep their behavior under the new header/tab chrome.
- Tokens used throughout so dark and light both work; split pane stacks on
  narrow widths. Existing ProjectsApp tests pass; added Workspace default +
  tab-switch tests. Board internals unchanged (already token-styled).
The controller registers itself as worker 'local' on every boot; that is not a
cluster event worth notifying about. Only remote workers joining or returning
online now fire a notification.
…witching)

Collapse the 248px project list to a 56px rail (mark avatars stay clickable so
you can still switch projects) to give the workspace/chat area more room.
Desktop-only; mobile keeps its split view.
…n light theme)

The mobile chat sidebar's Projects and Archived sections hardcoded white-alpha
text colors, which vanish on the light theme (project/channel names invisible).
Use shell-text tokens so they adapt to the active theme.
…reply view)

The fullscreen/side thread panel used bg-shell-surface (~4% alpha), so the chat
list behind bled through and the parent message rendered garbled/overlapping.
Use the opaque bg-shell-bg, tokenize the author/content text (was white-only,
invisible in light theme), and wrap long content.
…ck drain) (#942)

* fix(lifespan): bound background-task cancellation so shutdown never strands

The controller restart was slow (~51s) because the FastAPI lifespan SHUTDOWN
phase gathered background tasks with an unbounded asyncio.gather. If any
long-lived loop (event brokers, health/reap loops, provider refresh, A2A
poller, LiteLLM bringup, base-image prefetch, the store_popularity warmer, or
the local-heartbeat loop) did not unwind promptly on cancel, the lifespan
context manager blocked until systemd SIGKILLed the process at
TimeoutStopUSec (~45s). The new process then booted in ~5s, so the entire cost
was SHUTDOWN.

Make shutdown deterministic and bounded:
- Add task_utils.cancel_and_wait: cancels tracked tasks and waits with
  asyncio.wait(timeout=...). Unlike wait_for+gather it does not await the
  cancellation of still-pending tasks, so a task that refuses to unwind cannot
  hang the call. Stragglers are logged by name and we proceed.
- Lifespan shutdown now cancels the supervised _background_tasks set plus the
  local-heartbeat task under one 5s budget instead of an unbounded gather, and
  the separate unbounded await of the heartbeat task is removed.

Also fix the 401 that made the systemd graceful-stop drain a no-op: the
taos-graceful-stop hook POSTs /api/system/prepare-shutdown from localhost with
no session cookie. AuthMiddleware now exempts POST to that exact path for
loopback callers only (127.0.0.1 / ::1, checked via request.client, not
X-Forwarded-For), so the local drain works while remote callers still hit the
session gate.

TimeoutStopUSec is left unchanged (install-script concern); shutdown is just
fast enough that the timeout is never reached.

Tests: cancel_and_wait cancels tracked loops within the budget and reports an
uncancellable straggler without blocking; prepare-shutdown is reachable from
loopback (v4 and v6) without a session, rejected from a remote address, and
the exemption is path-scoped so other APIs stay gated on loopback.

* fix(auth): correct loopback-drain docstring (controller binds 0.0.0.0)

Address gitar security finding: the rationale wrongly claimed taOS binds
127.0.0.1. It binds 0.0.0.0, so safety comes from request.client.host being the
immediate TCP peer (a remote caller cannot make it loopback) and from NOT
trusting X-Forwarded-For, not from the bind address. Logic unchanged.
The split-pane divider showed a col-resize cursor but did nothing. Wire pointer
drag (with pointer capture + min-width clamps) so the chat and preview panes
resize.
…ang 45s

The real restart-hang cause: uvicorn had no timeout_graceful_shutdown, so on
SIGTERM it waited indefinitely for long-lived connections (SSE, heartbeats) to
close, never reaching the lifespan shutdown, until systemd SIGKILLed at the 45s
stop timeout. Set timeout_graceful_shutdown=5 on all uvicorn configs. Follows
the #942 background-task cancel (necessary but not sufficient on its own).
…45s-restart fix)

The two uvicorn servers each installed their own SIGTERM handler and the second
overrode the first, so on SIGTERM only the proxy server got should_exit; the
main server was then force-cancelled mid-serve, which hung the 45s stop. Neuter
uvicorn's per-server signal capture and install ONE handler that flips should_exit
on both, then await the survivor gracefully (bounded) instead of force-cancelling.
…wn (THE 45s-restart fix)

py-spy on the hung process proved the real cause: the asyncio loop exits fine,
but Python's threading._shutdown() blocks on a non-daemon aiosqlite connection
worker thread. github_identities_store (a BaseStore) was never closed, so its
thread kept the interpreter alive until the 45s SIGKILL. Close it, and add an
idempotent app.state BaseStore close-all backstop so a future omission cannot
reintroduce the hang. (The prior shutdown-bounding + dual-server-signal changes
stay as hardening; this is the actual fix.)
…der resize + chat fixes; tasks #66/67/68
The client POSTs mark-read with no body to mark the whole channel read, which
made request.json() raise JSONDecodeError and return 500. Tolerate an empty or
non-dict body.
Self-host tldraw assets, fix shape validation, and fill container.

- CSP/offline: pass assetUrls from getAssetUrlsByMetaUrl (@tldraw/assets/urls)
  to <Tldraw> so fonts/translations/icons/embed-icons load same-origin via
  Vite-bundled hashed URLs instead of cdn.tldraw.com, which the offline-first
  CSP blocks. Added @tldraw/assets 4.5.12 (matches tldraw).
- Validation: the geo/unknown fallback was the built-in geo shape receiving
  custom taos_* props, throwing ValidationError. Route the fallback to a new
  taos-generic custom shape that declares only geometry and carries taos_* in
  shape.meta. note/link/image keep taos_* in their own declared props.
- Layout: CanvasView height calc(100vh - 100px) -> 100% so the board fills its
  container inside the Workspace right pane and the standalone Canvas tab.
- Extracted elementToShape/shapeType into element-to-shape.ts and added a unit
  test covering each kind plus the meta-based fallback.
… in CSP

Two remaining canvas console errors after #943: (1) store.put rejected
taos-generic because createTLStore had no custom shape schema (shapeUtils were
only on <Tldraw>, which governs rendering not validation) -- pass shapeUtils to
createTLStore. (2) tldraw's bundled translation data: URI was blocked by
connect-src -- add data: to the CSP connect-src.
- add_element did a plain INSERT, so re-sending an existing element (hydrate
  then nudge) hit a UNIQUE constraint and returned 500. Make it an ON CONFLICT
  upsert.
- tldraw renders images via blob: URLs, blocked by img-src; add blob:.
…mobile

- MobileAppWindow content bg was hardcoded rgba(15,15,35) which read as an
  indigo flash on the dark theme; use var(--color-shell-bg) (graphite).
- The Projects list sidebar kept its fixed 248px inside the mobile full-width
  list screen, leaving an empty strip showing the window bg; make .sidebar
  100% width under the mobile media query.
… (#945)

* feat(mail): Phase 1 Mail app, multi-account IMAP/SMTP client (#60)

Frontend (desktop/src/apps/MailApp):
- New "mail" platform app in the registry (envelope icon, singleton,
  1200x800 default), built from the approved mock with theme tokens
  (dark + light) on the Store/Images/GitHub visual bar.
- Account switcher, folder list (Inbox/Sent/Drafts/Archive/Trash plus
  the server's own mailboxes), message list with search and
  All/Unread/Flagged filters, reading pane (headers + body +
  attachments + reply/forward/archive/delete actions), and a Compose
  overlay (To/Cc/Subject/body, send/discard).
- Add-account form, mobile-adaptive layout (stacks list and reading
  pane), and a Share / Send to entry-point stub for task #69.
- The Agent accounts group and the send-as From switcher are shown
  disabled as a Phase 2 affordance, not wired.

Backend (tinyagentos):
- MailAccountStore: per-user account metadata over aiosqlite. The
  password is never stored here; only a SecretsStore pointer is kept.
  Store is initialised in the app lifespan and closed on shutdown so it
  does not leak a connection thread.
- mail_client: IMAP folder/message listing, single-message fetch, and
  SMTP send over stdlib imaplib/smtplib (matching the existing email
  connector), with all blocking IO run via asyncio.to_thread. Envelope
  and detail parsing are pure functions for testing.
- /api/mail routes (accounts CRUD, folders, messages list, message get,
  send), per-user scoped via the auth_context current_user dependency.
  Passwords are written to and read from the SecretsStore.

Tests:
- test_mail_store (account CRUD, user scoping, secret indirection, no
  plaintext password column) and test_mail_client (envelope/detail
  parsing, flags, encoded headers, attachments). Frontend MailApp
  vitest covers list/filter/open/compose/share/empty.

Deferred to Phase 2 (clean TODOs): agent send-as, OAuth, full share
sheet (#69), and push/IDLE new-mail.

* fix(mail): use stable IMAP UIDs, validate folder names, bound list size

Address Gitar review findings on the Mail app:
- list/get used IMAP sequence numbers as the message id, which the server
  renumbers on expunge; switch to UID SEARCH/FETCH so the id stays stable
- folder arrived untrusted and was interpolated into SELECT; reject names
  containing quotes or CR/LF (MailFolderError -> 400) to block IMAP injection
- limit was unbounded; clamp to MAX_MESSAGE_LIMIT (200) so one request cannot
  tie up the connection with thousands of per-id fetches
- add_account now rolls back the account row if the secret write or re-point
  fails, instead of orphaning a row that points at a missing secret

Add folder-validation tests.
Freshness sweep: #945 (Mail app) and the canvas-stabilization train merged to
dev; add Mail to the README platform-app list (25 -> 26) and roll STATUS to the
current dev tip with the merged set and the two open PRs (#944, #946).
* feat(browser): redesign chrome to the approved design bar

Restyle the Browser app chrome to the approved mock (task #66): a tab
strip with a Proxy/Streamed segmented engine toggle, a unified toolbar
with back/forward/reload/home plus a pill omnibox (lock + security
state), an agent-presence pill, and a profile chip whose dropdown lists
profiles with avatar rows. Tokenize against the real taOS design tokens
(dark + light), matching the Store/Images bar.

Behavior is unchanged: tabs, navigation, the URL-rewriting proxy browser,
the streamed WebRTC Chromium session, profiles/profile-switching, agent
sessions, and settings all keep their existing state and handlers.

- New BrowserModeToggle surfaces the proxy-vs-streamed engine as a
  segmented control. Streamed drives the existing escalate lifecycle
  (POST /api/browser/sessions, poll, setTabLiveSession); Proxy clears the
  liveSession. Covered by BrowserModeToggle.test.tsx.
- Chrome becomes the unified toolbar (adds a Home button, wraps AddressBar
  in a pill omnibox with a connection-security lock); the engine toggle
  moves to the tab strip.
- TabStrip, ProfileSwitcher, SettingsPanel, AddressBar, AgentPresencePill
  retokenized to live tokens (replacing the dead shell-hover /
  shell-border-subtle classes in touched files).
- BrowserApp reorders desktop layout to tab strip over toolbar and gives
  the mobile address bar an omnibox pill.

* fix(browser): clean up poll timer on unmount + honour proxy cancellation

Gitar flagged two bugs in BrowserModeToggle:
- the re-poll setTimeout was never cleared on unmount, so a closed tab kept
  fetching and calling setPhase on a dead component
- goStreamed ignored a mid-flight Proxy click: after the POST resolved it
  forced the tab into a streamed session even though the user had cancelled

Add an unmount cleanup effect and re-check cancelledRef after the POST.

* fix(browser): re-check cancellation after the session JSON parse

Gitar follow-up: the post-POST guard left a residual window during the
await resp.json() parse. Re-check cancelledRef after the parse (and on the
parse-error path) so a Proxy click or unmount mid-parse cannot commit a live
session or setPhase on an unmounted component.
#946)

* feat(apps): backend install-state routes for optional frontend apps

Reddit/YouTube/GitHub/X become optional Store installs instead of always-on
desktop apps. Add /api/apps/optional/installed + per-app install/uninstall,
backed by installed_apps (kind=frontend-app, no service spawned), gated to an
allowlist so the endpoint can't write arbitrary rows.

* feat(apps): make Reddit/YouTube/GitHub/X optional Store installs

These four were always-on platform apps. Mark them optional in the registry
so the desktop launcher (launchpad, search, mobile home) only shows them once
installed. Add a 'taOS Apps' section to the Store with instant one-click
Install/Remove (no device target or progress -- they are frontend-only), wired
to /api/apps/optional/* and an APP_OPTIONAL_CHANGED event so the launcher
updates immediately.

Update the Launchpad and mobile-home tests for the new default (optional apps
absent until installed).

* test(apps): cover optional frontend-app install/uninstall routes

Allowlist rejection (404), install->listed, uninstall->removed, and that
installed optional apps stay out of the proxy-services list.

* fix(mobile): surface installed optional apps on the home grid

Gitar ⚠️ on #946: the mobile home grid (the only launcher on phones) excluded
optional apps from the default and never re-added them, so an installed
Reddit/YouTube/GitHub/X could never appear. Append any installed optional app
not already placed to the last home page at render.
…14a1e3

Freshness sweep: #944/#945/#946 merged + deployed/verified on Pi. #946 made the
four social apps optional Store installs, so split them out of the always-on
platform list (22 platform + 4 optional). Roll STATUS to the current dev tip
with the merged set, the two open PRs (#947 browser, #948 agent screenshot),
and the new tasks #70/#71/#72.
Standing rule (Jay): strip em dashes from docs when found; replace with --.
jaylfc added 10 commits June 15, 2026 21:15
…#947)

* feat(browser): taos.my homepage + dark/light scheme for proxied sites

Homepage: the browser opens to https://taos.my (initial tab) and the Home
button navigates there; new blank tabs still open about:blank.

Colour scheme: proxied sites that support dark/light now render to match the
taOS theme. The active scheme rides the redeem URL (cs=) -> a taos_cs cookie on
the proxy origin -> proxy_get injects a <meta name=color-scheme> plus a
taos-color-scheme meta that copilot.js reads to emulate prefers-color-scheme
(matchMedia) before page scripts run. Switching the taOS theme re-mints the
redeem URL so pages re-theme on next load. Pure CSS @media(prefers-color-scheme)
still follows the host browser (an iframe's UA preference cannot be overridden
from the parent); the streamed engine will get full control via CDP once an
ARM-capable node exists (separate task).

* fix(csp): allow framing the browser-proxy origin (frame-src)

The desktop CSP had no frame-src, so default-src 'self' blocked the BrowserApp
from framing the cross-origin proxy (separate port) -- proxied pages could not
render. Add frame-src naming the proxy origin (same host, proxy port), derived
per-request; 'self' still covers single-port mode. Surfaced by the new taos.my
homepage, which frames the proxy on first load.

* fix(csp): validate Host before using it in frame-src (header injection)

The Host header is attacker-controllable; interpolating it into the CSP
unvalidated allowed CSP-directive injection. Restrict to a bare hostname/IP
charset (no spaces, ';', quotes) and fall back to no proxy frame-src on a
non-conforming Host. Add regression tests.
* feat(desktop): agent-callable screenshot with capture flash effect

Adds an agent screenshot round-trip on the existing desktop-command channel:
POST /api/desktop/screenshot emits a 'screenshot' command to every open desktop
for the user and awaits the result; the desktop rasterises its viewport
(modern-screenshot) and POSTs it back to /api/desktop/screenshot-result, which
resolves the request and returns a PNG. A subtle shutter flash (white veil +
inset frame, reduced-motion aware) plays on capture, excluded from the image.

This lets an agent visually verify the OS it drives -- the missing piece next
to open_app / arrange_windows. DOM rasterisation cannot read cross-origin
iframes (the Browser's proxied page); the desktop chrome + native apps capture
fully. Full-pixel capture incl. web content stays on the user-gesture
getDisplayMedia path in the assistant panel.

* harden(screenshot): owner-scope result, cap payload size, get_running_loop

Gitar review follow-ups on the screenshot round-trip: track the owner user per
request and re-check it in screenshot-result (defence in depth over the
unguessable, user-scoped request_id); reject image payloads over ~24 MB base64
(413); use asyncio.get_running_loop().

* feat(screenshot): full-fidelity capture via a persistent getDisplayMedia grant

Answer to: can the PWA capture full screenshots client-side? Yes -- one
getDisplayMedia grant (one gesture, one prompt) yields a persistent MediaStream;
agent screenshots then grab frames from it on demand with no re-prompt, and the
capture includes cross-origin iframes (the Browser's proxied page) that DOM
rasterisation cannot read. The agent screenshot handler prefers a live capture
frame and falls back to DOM raster when no grant is active. A MonitorUp toggle
in the assistant panel grants/stops the share; the browser shows its own sharing
indicator and the user can stop anytime. A fully invisible/persistent grant
(survives reload, no indicator) needs a native wrapper or extension -- noted.

* fix(screenshot): flash after capture + sync grant button on native stop

Gitar review on #948:
- the shutter flash was dispatched before capture, so it leaked into the
  full-fidelity getDisplayMedia frame (that path captures the real composited
  screen, where the overlay is not excludable like the DOM-raster filter).
  Capture first, then flash.
- the grant button went stale when the user stopped sharing from the browser's
  native bar. Emit a SCREEN_CAPTURE_CHANGED event on grant/revoke and have the
  button re-read state from it.
The taOS agent panel hardcoded rgba(21,22,37,0.92), which read as purple on
the graphite/light themes. Use var(--color-shell-bg-glass) so it matches the
active theme.
On reload the whole server feed arrives at once (all unread), and the toast
layer popped every unread item -- spamming toasts on every load. Toasts now
fire only for genuinely fresh notifications (created within 20s) and at most
once per session; the backlog still fills the bell, it just does not pop.
… open canvas+browser issues

#947+#948 merged + deployed; agent-screenshot demo verified live via the
control API (no Playwright). Record the agent-panel + notification fixes, the
Tailscale private-HTTPS setup + the 6970 port-conflict lesson, #73 single-port
follow-up, and the open Projects-canvas frontend crash.
… Projects (#949)

Jay hit a Projects canvas crash (frontend tldraw error; backend healthy). Two
defences:
- hydrateEditor isolates each element in try/catch, so one malformed shape
  (bad payload, props the tldraw validator rejects, a version-skewed
  tldraw_shape) is skipped instead of throwing out of editor.run and crashing
  the whole canvas.
- wrap CanvasBoard in AppErrorBoundary (keyed by project) so any remaining
  render crash shows a fallback rather than taking down the Projects app.
…xact queries (#950)

Gitar 💡 follow-up on #947: the proxied-site matchMedia patch matched ANY query
containing prefers-color-scheme and keyed only on the dark/light term, so a
compound query like (min-width:600px) and (prefers-color-scheme: dark) -- or a
valueless (prefers-color-scheme) -- returned a wrong result. Only override a
query that is EXACTLY (prefers-color-scheme: dark|light); everything else falls
through to the real matchMedia.
…render (#951)

The Projects canvas renders note/link/image elements through tldraw custom
shapes whose props schema is strict (every field type-checked at
editor.createShape). The backend only validates an element's kind, not its
payload, so an agent can store a note/link/image whose payload is missing a
field, has the wrong type (e.g. font_size as the string "14"), or is empty.

Feeding that raw payload into props throws a ValidationError, so the element
silently vanishes from the canvas (and previously took the whole board down
before the per-element guard). elementToShape now coerces every payload field
to its declared type with a sensible default, and coerces geometry and
author_kind too, so imperfect agent-authored content renders as a proper
note/link/image instead of disappearing.

Reproduced live: notes written with a missing/string/empty font_size produced
'ValidationError: ... font_size: Expected number, got undefined/a string' and
were skipped. With coercion they render with defaults.
… tldraw->Konva decision, new Store-app studios/office/guided-mode tasks
@qodo-code-review

Copy link
Copy Markdown

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

@github-actions

Copy link
Copy Markdown

👋 Thanks for the PR! This one targets master, which is our
stable branch (it's what live installs track). Please retarget it to
dev — click Edit next to the PR title and change the base
branch dropdown from master to dev. Your commits and any review
carry over, nothing is lost.

See CONTRIBUTING.md for the branch model.

@coderabbitai

coderabbitai Bot commented Jun 16, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

Pull request was closed or merged during review

📝 Walkthrough

Walkthrough

This PR adds a Mail app and mail APIs, optional app install flows, browser and Projects updates, server notification syncing, agent-triggered screenshots, proxied theme propagation, bounded shutdown and auth/security hardening, plus documentation and UI styling refreshes.

Changes

Browser shell and proxied theme flow

Layer / File(s) Summary
Browser shell and streamed toggle
desktop/src/apps/BrowserApp/*, desktop/src/stores/browser-store.ts
Adds the Proxy/Streamed segmented control, updates browser shell composition around the tab strip and toolbar, and changes the first browser tab to HOME_URL.
Browser shell styling polish
desktop/src/apps/BrowserApp/AddressBar.tsx, .../AgentPresencePill.tsx, .../ProfileSwitcher.tsx, .../SettingsPanel.tsx, tests/.../AgentPresencePill.test.tsx
Restyles the omnibox, agent pill, profile menu, and settings panel to match the revised browser chrome.
Proxied color scheme propagation
desktop/src/apps/BrowserApp/TabRenderer.tsx, desktop/src/lib/browser-proxy-config.ts, tinyagentos/routes/desktop_browser/*, tinyagentos/browser_proxy_origin.py, tests/routes/desktop_browser/test_injector.py
Threads light/dark scheme through redeem URLs, cookies, injected meta tags, and proxied-page matchMedia emulation.

Mail app and mail service

Layer / File(s) Summary
Mail storage and server routes
tinyagentos/mail_store.py, tinyagentos/mail_client.py, tinyagentos/routes/mail.py, tinyagentos/routes/__init__.py, tinyagentos/app.py
Adds mail account storage, IMAP/SMTP client logic, authenticated mail routes, and app lifecycle wiring for the mail store.
Mail frontend app
desktop/src/lib/mail.ts, desktop/src/apps/MailApp/*
Adds the Mail desktop app, frontend mail API client, account setup form, folder/message views, reading pane, and compose overlay.
Mail validation tests
desktop/src/apps/MailApp/MailApp.test.tsx, tests/test_mail_client.py, tests/test_mail_store.py
Covers frontend mail flows, IMAP message parsing, folder validation, and scoped mail-account persistence behavior.

Projects workspace and canvas resilience

Layer / File(s) Summary
Projects workspace layout and presence
desktop/src/apps/ProjectsApp/*, desktop/src/apps/ProjectsApp/__tests__/*
Redesigns the Projects desktop sidebar, adds collapse mode, defaults workspaces to a new Workspace tab, derives member and agent presence, and adds a split workspace pane.
Canvas coercion and fallback shapes
desktop/package.json, desktop/src/apps/ProjectsApp/canvas/*, desktop/src/apps/ProjectsApp/__tests__/element-to-shape.test.ts, tinyagentos/projects/canvas/store.py
Adds typed canvas element coercion, a generic fallback shape, self-hosted Tldraw assets, guarded hydration, round-trip payload extraction, and canvas-element upserts.

Optional apps and desktop surfaces

Layer / File(s) Summary
Optional app registry and APIs
desktop/src/lib/app-event-bus.ts, desktop/src/hooks/use-installed-optional-apps.ts, desktop/src/registry/*, tinyagentos/routes/apps.py, tests/test_apps_installed.py
Marks selected apps as optional, adds installed-state APIs and frontend refresh events, and exposes optional app metadata.
Store and launch surfaces
desktop/src/apps/StoreApp/*, desktop/src/components/Launchpad.tsx, desktop/src/components/SearchPalette.tsx, desktop/src/components/mobile/MobileHomePages.tsx, desktop/src/stores/mobile-home-store.ts, desktop/src/components/__tests__/Launchpad.test.tsx, desktop/src/stores/mobile-home-store.test.ts
Shows optional apps in Store, limits launch/search/mobile home surfaces to installed optional apps, and updates related tests.

Notifications, screenshots, and desktop capture

Layer / File(s) Summary
Server notifications and routing
desktop/src/App.tsx, desktop/src/hooks/use-server-notifications.ts, desktop/src/lib/server-notifications.ts, desktop/src/stores/notification-store.ts, desktop/src/components/NotificationCentre.tsx, desktop/src/components/NotificationToast.tsx, desktop/src/lib/server-notifications.test.ts, desktop/src/components/NotificationCentre.test.tsx, desktop/src/stores/notification-store.test.ts, tinyagentos/cluster/manager.py
Fetches backend notifications into the desktop store, preserves local read and dismissed state across polls, routes actionable notifications into app windows, and limits repeat toast display.
Desktop screenshot round-trip
desktop/package.json, desktop/src/lib/screen-capture.ts, desktop/src/hooks/use-desktop-command-stream.ts, desktop/src/components/ScreenshotFlash.tsx, desktop/src/components/Desktop.tsx, desktop/src/components/TaosAssistantPanel.tsx, tinyagentos/desktop_control/broker.py, tinyagentos/routes/desktop_control.py, tests/test_desktop_screenshot.py
Adds request/response screenshot capture through the desktop command stream, a flash overlay, persistent capture permission helpers, and backend result correlation endpoints.

Runtime shutdown, auth, security, and docs polish

Layer / File(s) Summary
Desktop entry points and docs
desktop/src/apps/FilesApp.tsx, desktop/src/components/DesktopIcons.tsx, desktop/src/apps/SettingsApp.tsx, README.md, docs/STATUS.md
Allows file windows and settings windows to start from provided path or section state and refreshes product, install, and status documentation.
Bounded shutdown and loopback control
tinyagentos/task_utils.py, tinyagentos/__main__.py, tinyagentos/app.py, tinyagentos/auth_middleware.py, tests/test_task_utils_shutdown.py, tests/test_prepare_shutdown_loopback.py
Adds timeout-bounded task and server shutdown handling, mail-store shutdown wiring, and a loopback-only exemption for prepare-shutdown requests.
Security headers and shell polish
tinyagentos/middleware/security_headers.py, tests/test_security_headers.py, tinyagentos/routes/chat.py, desktop/src/apps/MessagesApp.tsx, desktop/src/apps/chat/ThreadPanel.tsx, desktop/src/components/mobile/MobileAppWindow.tsx
Builds CSP dynamically with validated proxy frame origins, hardens chat mark-read body parsing, and applies theme-token styling updates in several shell views.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant ScreenshotRoute
  participant DesktopBroker
  participant DesktopUI
  participant ScreenshotResultRoute
  Client->>ScreenshotRoute: POST /api/desktop/screenshot
  ScreenshotRoute->>DesktopBroker: register_result + publish screenshot command
  DesktopBroker-->>DesktopUI: screenshot request_id
  DesktopUI->>ScreenshotResultRoute: POST /api/desktop/screenshot-result
  ScreenshotResultRoute->>DesktopBroker: resolve_result(request_id)
  DesktopBroker-->>ScreenshotRoute: image or error
  ScreenshotRoute-->>Client: image/png or HTTP error
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

  • jaylfc/taOS#945: Adds the same Mail app phase and overlaps on the mail frontend, mail client, routes, and storage modules.
  • jaylfc/taOS#948: Implements the same desktop screenshot request/capture pipeline across desktop command streaming, flash UI, and backend screenshot routes.
  • jaylfc/taOS#946: Matches the optional-app installation flow across Store UI, installed-app hooks, launch surfaces, and /api/apps/optional/... routes.

Poem

🐇 I hopped through tabs and mail today,
with screenshots flashing on the way.
Optional burrows now appear,
while projects draw without a fear.
Notifications softly ring—
what a tidy springtime thing.

✨ 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 dev

Comment on lines +281 to +294
def _get_message_blocking(
cfg: MailAccountConfig, folder: str, uid: str
) -> MessageDetail | None:
_validate_folder(folder)
conn = _imap_connect(cfg)
try:
conn.select(f'"{folder}"', readonly=True)
# Fetch by UID to match the stable id handed out by list_messages.
typ, msg_data = conn.uid("FETCH", uid, "(RFC822)")
if typ != "OK" or not msg_data or not msg_data[0]:
return None
raw_email = msg_data[0][1]
msg = email.message_from_bytes(raw_email)
return parse_detail(uid, msg)

@gitar-bot gitar-bot Bot Jun 16, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Security: IMAP message UID not validated before FETCH (injection gap)

_get_message_blocking (tinyagentos/mail_client.py:281-294) passes the client-supplied uid path parameter straight into conn.uid("FETCH", uid, "(RFC822)"). The route GET /api/mail/accounts/{account_id}/messages/{uid} (mail.py:197-220) takes uid from the URL and forwards it unmodified. imaplib does not quote or sanitize command arguments, so a uid containing spaces or CR/LF can append extra IMAP protocol tokens on the connection — exactly the class of attack the module guards against for folder names via _validate_folder (mail_client.py:40-49). The folder argument is validated but the equally-untrusted UID is not, leaving an inconsistent gap.

While the connection is the user's own authenticated session (limiting blast radius), the code already treats this exact vector as worth defending for folders, so the UID should get the same treatment. Add a validator that rejects anything that is not a bare IMAP UID (digits, or digit ranges/sets like 1:5), e.g. if not re.fullmatch(r"[0-9:,*]+", uid): raise MailFolderError(...), and call it in _get_message_blocking before conn.uid(...).

Validate the UID the same way folder names are validated (requires import re).:

def _validate_uid(uid: str) -> str:
    """Reject UID values that are not a bare IMAP UID/UID-set."""
    if not uid or not re.fullmatch(r"[0-9][0-9:,*]*", uid):
        raise MailFolderError(f"invalid message uid: {uid!r}")
    return uid

# in _get_message_blocking, before conn.select(...):
    _validate_folder(folder)
    _validate_uid(uid)

Was this helpful? React with 👍 / 👎

Comment on lines +299 to +313
def _build_outgoing(
cfg: MailAccountConfig,
to: str,
subject: str,
body: str,
cc: str = "",
attachments: list[tuple[str, bytes, str]] | None = None,
) -> MIMEMultipart:
msg = MIMEMultipart()
msg["From"] = cfg.email_address or cfg.username
msg["To"] = to
if cc:
msg["Cc"] = cc
msg["Subject"] = subject
msg.attach(MIMEText(body, "plain"))

@gitar-bot gitar-bot Bot Jun 16, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Security: SMTP send allows header injection via subject/to/cc

_build_outgoing (tinyagentos/mail_client.py:299-323) assigns user-supplied values directly to MIME headers: msg["To"] = to, msg["Subject"] = subject, msg["Cc"] = cc. These come from the unvalidated SendBody fields in the send route (mail.py:43-48, 223-242). Depending on the active email policy, embedded CR/LF in these values can fold/inject additional headers (e.g. Bcc, extra recipients) into the outgoing message. The recipient list is also derived by naive comma-splitting of to/cc, so injected newlines or stray addresses propagate to sendmail.

The credentials are the user's own, so impact is limited, but it is good practice to strip CR/LF from header values (or rely on the modern email.message.EmailMessage policy which raises on illegal header characters). Sanitize to/subject/cc by rejecting/stripping \r and `` before building the message.

Strip CR/LF from header-bound fields before assignment.:

def _clean_header(value: str) -> str:
    # Drop CR/LF so a crafted value cannot inject extra headers.
    return value.replace("\r", " ").replace("
", " ").strip()

# in _build_outgoing:
    msg["To"] = _clean_header(to)
    if cc:
        msg["Cc"] = _clean_header(cc)
    msg["Subject"] = _clean_header(subject)

Was this helpful? React with 👍 / 👎

Comment on lines +35 to +49
def _strip_port(host: str) -> str:
return host.rsplit(":", 1)[0] if ":" in host else host


def _proxy_frame_origin(request: Request) -> str:
"""The browser-proxy origin (same host, proxy port) to allow in frame-src,
or "" when single-port (proxy served from the main origin, already 'self')."""
state = request.app.state
main_port = getattr(state, "main_port", None)
proxy_port = getattr(state, "browser_proxy_port", 0)
if not main_port or not proxy_port or main_port == proxy_port:
return ""
host = _strip_port(request.headers.get("host") or "")
if not host or not _SAFE_HOST_RE.fullmatch(host):
return ""

@gitar-bot gitar-bot Bot Jun 16, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Bug: _strip_port corrupts bare IPv6 hosts in CSP frame-src

_strip_port in security_headers.py (lines 35-36) does host.rsplit(":", 1)[0] if ":" in host else host. For a bracketed IPv6 host without a port (e.g. [::1]), the host contains :, so it is wrongly truncated to [::1 (and any [2001:db8::1] to [2001:db8::1). _SAFE_HOST_RE permits [ and ], so such a malformed host then gets interpolated into the frame-src origins (line 54), producing a broken/invalid CSP source for IPv6 deployments. Only host:port forms strip correctly. Consider only stripping when the trailing segment is numeric, or detect the ] bracket for IPv6 before splitting.

Handle bracketed IPv6 literals and only strip a numeric trailing port.:

def _strip_port(host: str) -> str:
    # IPv6 literal: only a port lives after the closing bracket.
    if host.startswith("["):
        end = host.find("]")
        return host[: end + 1] if end != -1 else host
    # IPv4 / hostname: strip a trailing :port only.
    head, sep, tail = host.rpartition(":")
    return head if sep and tail.isdigit() else host

Was this helpful? React with 👍 / 👎

@gitar-bot

gitar-bot Bot commented Jun 16, 2026

Copy link
Copy Markdown
Code Review ⚠️ Changes requested 0 resolved / 3 findings

Promotes development features including Mail app, browser redesign, and canvas stabilization, but changes are requested due to IMAP/SMTP header injection vulnerabilities and an IPv6 parsing error in CSP headers.

⚠️ Security: IMAP message UID not validated before FETCH (injection gap)

📄 tinyagentos/mail_client.py:281-294 📄 tinyagentos/mail_client.py:40-49 📄 tinyagentos/routes/mail.py:197-211

_get_message_blocking (tinyagentos/mail_client.py:281-294) passes the client-supplied uid path parameter straight into conn.uid("FETCH", uid, "(RFC822)"). The route GET /api/mail/accounts/{account_id}/messages/{uid} (mail.py:197-220) takes uid from the URL and forwards it unmodified. imaplib does not quote or sanitize command arguments, so a uid containing spaces or CR/LF can append extra IMAP protocol tokens on the connection — exactly the class of attack the module guards against for folder names via _validate_folder (mail_client.py:40-49). The folder argument is validated but the equally-untrusted UID is not, leaving an inconsistent gap.

While the connection is the user's own authenticated session (limiting blast radius), the code already treats this exact vector as worth defending for folders, so the UID should get the same treatment. Add a validator that rejects anything that is not a bare IMAP UID (digits, or digit ranges/sets like 1:5), e.g. if not re.fullmatch(r"[0-9:,*]+", uid): raise MailFolderError(...), and call it in _get_message_blocking before conn.uid(...).

Validate the UID the same way folder names are validated (requires `import re`).
def _validate_uid(uid: str) -> str:
    """Reject UID values that are not a bare IMAP UID/UID-set."""
    if not uid or not re.fullmatch(r"[0-9][0-9:,*]*", uid):
        raise MailFolderError(f"invalid message uid: {uid!r}")
    return uid

# in _get_message_blocking, before conn.select(...):
    _validate_folder(folder)
    _validate_uid(uid)
💡 Security: SMTP send allows header injection via subject/to/cc

📄 tinyagentos/mail_client.py:299-313 📄 tinyagentos/mail_client.py:326-340 📄 tinyagentos/routes/mail.py:43-48 📄 tinyagentos/routes/mail.py:223-237

_build_outgoing (tinyagentos/mail_client.py:299-323) assigns user-supplied values directly to MIME headers: msg["To"] = to, msg["Subject"] = subject, msg["Cc"] = cc. These come from the unvalidated SendBody fields in the send route (mail.py:43-48, 223-242). Depending on the active email policy, embedded CR/LF in these values can fold/inject additional headers (e.g. Bcc, extra recipients) into the outgoing message. The recipient list is also derived by naive comma-splitting of to/cc, so injected newlines or stray addresses propagate to sendmail.

The credentials are the user's own, so impact is limited, but it is good practice to strip CR/LF from header values (or rely on the modern email.message.EmailMessage policy which raises on illegal header characters). Sanitize to/subject/cc by rejecting/stripping \r and `` before building the message.

Strip CR/LF from header-bound fields before assignment.
def _clean_header(value: str) -> str:
    # Drop CR/LF so a crafted value cannot inject extra headers.
    return value.replace("\r", " ").replace("
", " ").strip()

# in _build_outgoing:
    msg["To"] = _clean_header(to)
    if cc:
        msg["Cc"] = _clean_header(cc)
    msg["Subject"] = _clean_header(subject)
💡 Bug: _strip_port corrupts bare IPv6 hosts in CSP frame-src

📄 tinyagentos/middleware/security_headers.py:35-49

_strip_port in security_headers.py (lines 35-36) does host.rsplit(":", 1)[0] if ":" in host else host. For a bracketed IPv6 host without a port (e.g. [::1]), the host contains :, so it is wrongly truncated to [::1 (and any [2001:db8::1] to [2001:db8::1). _SAFE_HOST_RE permits [ and ], so such a malformed host then gets interpolated into the frame-src origins (line 54), producing a broken/invalid CSP source for IPv6 deployments. Only host:port forms strip correctly. Consider only stripping when the trailing segment is numeric, or detect the ] bracket for IPv6 before splitting.

Handle bracketed IPv6 literals and only strip a numeric trailing port.
def _strip_port(host: str) -> str:
    # IPv6 literal: only a port lives after the closing bracket.
    if host.startswith("["):
        end = host.find("]")
        return host[: end + 1] if end != -1 else host
    # IPv4 / hostname: strip a trailing :port only.
    head, sep, tail = host.rpartition(":")
    return head if sep and tail.isdigit() else host
🤖 Prompt for agents
Code Review: Promotes development features including Mail app, browser redesign, and canvas stabilization, but changes are requested due to IMAP/SMTP header injection vulnerabilities and an IPv6 parsing error in CSP headers.

1. ⚠️ Security: IMAP message UID not validated before FETCH (injection gap)
   Files: tinyagentos/mail_client.py:281-294, tinyagentos/mail_client.py:40-49, tinyagentos/routes/mail.py:197-211

   `_get_message_blocking` (tinyagentos/mail_client.py:281-294) passes the client-supplied `uid` path parameter straight into `conn.uid("FETCH", uid, "(RFC822)")`. The route `GET /api/mail/accounts/{account_id}/messages/{uid}` (mail.py:197-220) takes `uid` from the URL and forwards it unmodified. imaplib does not quote or sanitize command arguments, so a `uid` containing spaces or CR/LF can append extra IMAP protocol tokens on the connection — exactly the class of attack the module guards against for folder names via `_validate_folder` (mail_client.py:40-49). The folder argument is validated but the equally-untrusted UID is not, leaving an inconsistent gap.
   
   While the connection is the user's own authenticated session (limiting blast radius), the code already treats this exact vector as worth defending for folders, so the UID should get the same treatment. Add a validator that rejects anything that is not a bare IMAP UID (digits, or digit ranges/sets like `1:5`), e.g. `if not re.fullmatch(r"[0-9:,*]+", uid): raise MailFolderError(...)`, and call it in `_get_message_blocking` before `conn.uid(...)`.

   Fix (Validate the UID the same way folder names are validated (requires `import re`).):
   def _validate_uid(uid: str) -> str:
       """Reject UID values that are not a bare IMAP UID/UID-set."""
       if not uid or not re.fullmatch(r"[0-9][0-9:,*]*", uid):
           raise MailFolderError(f"invalid message uid: {uid!r}")
       return uid
   
   # in _get_message_blocking, before conn.select(...):
       _validate_folder(folder)
       _validate_uid(uid)

2. 💡 Security: SMTP send allows header injection via subject/to/cc
   Files: tinyagentos/mail_client.py:299-313, tinyagentos/mail_client.py:326-340, tinyagentos/routes/mail.py:43-48, tinyagentos/routes/mail.py:223-237

   `_build_outgoing` (tinyagentos/mail_client.py:299-323) assigns user-supplied values directly to MIME headers: `msg["To"] = to`, `msg["Subject"] = subject`, `msg["Cc"] = cc`. These come from the unvalidated `SendBody` fields in the send route (mail.py:43-48, 223-242). Depending on the active email policy, embedded CR/LF in these values can fold/inject additional headers (e.g. Bcc, extra recipients) into the outgoing message. The recipient list is also derived by naive comma-splitting of `to`/`cc`, so injected newlines or stray addresses propagate to `sendmail`.
   
   The credentials are the user's own, so impact is limited, but it is good practice to strip CR/LF from header values (or rely on the modern `email.message.EmailMessage` policy which raises on illegal header characters). Sanitize `to`/`subject`/`cc` by rejecting/stripping `\r` and `
   ` before building the message.

   Fix (Strip CR/LF from header-bound fields before assignment.):
   def _clean_header(value: str) -> str:
       # Drop CR/LF so a crafted value cannot inject extra headers.
       return value.replace("\r", " ").replace("
   ", " ").strip()
   
   # in _build_outgoing:
       msg["To"] = _clean_header(to)
       if cc:
           msg["Cc"] = _clean_header(cc)
       msg["Subject"] = _clean_header(subject)

3. 💡 Bug: _strip_port corrupts bare IPv6 hosts in CSP frame-src
   Files: tinyagentos/middleware/security_headers.py:35-49

   `_strip_port` in security_headers.py (lines 35-36) does `host.rsplit(":", 1)[0] if ":" in host else host`. For a bracketed IPv6 host without a port (e.g. `[::1]`), the host contains `:`, so it is wrongly truncated to `[::1` (and any `[2001:db8::1]` to `[2001:db8::1`). `_SAFE_HOST_RE` permits `[` and `]`, so such a malformed host then gets interpolated into the `frame-src` origins (line 54), producing a broken/invalid CSP source for IPv6 deployments. Only `host:port` forms strip correctly. Consider only stripping when the trailing segment is numeric, or detect the `]` bracket for IPv6 before splitting.

   Fix (Handle bracketed IPv6 literals and only strip a numeric trailing port.):
   def _strip_port(host: str) -> str:
       # IPv6 literal: only a port lives after the closing bracket.
       if host.startswith("["):
           end = host.find("]")
           return host[: end + 1] if end != -1 else host
       # IPv4 / hostname: strip a trailing :port only.
       head, sep, tail = host.rpartition(":")
       return head if sep and tail.isdigit() else host

Options

Auto-apply is off → Gitar will not commit updates to this branch.
Display: compact → Showing less information.

Comment with these commands to change:

Auto-apply Compact
gitar auto-apply:on         
gitar display:verbose         

Was this helpful? React with 👍 / 👎 | Gitar

@jaylfc jaylfc merged commit 98e459f into master Jun 16, 2026
10 of 12 checks passed
@jaylfc jaylfc deleted the dev branch June 16, 2026 12:40
@github-project-automation github-project-automation Bot moved this from Todo to Done in TinyAgentOS Roadmap Jun 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Development

Successfully merging this pull request may close these issues.

1 participant