diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..a4b9b33c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,30 @@ +# Changelog + +All notable changes to taOS are documented in this file. + +Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +Versions follow semver beta: `1.0.0-beta.N`, bumped on each dev->master promotion. + +## [Unreleased] + +## [1.0.0-beta.2] - 2026-06-16 + +### Added +- Mail app with IMAP/SMTP account setup, message list, read, and send. +- Reddit, YouTube, GitHub, and X apps available as optional Store installs. +- Agent-callable screenshot endpoint for desktop-control workflows. + +### Changed +- Browser app redesigned with the Store/Images design bar and taos.my set as the default homepage, with automatic dark/light scheme applied to proxied sites. +- Projects app shell redesigned with a Workspace hero tab. +- Notification bell wired to the backend feed with actionable click routing to the originating app or agent. +- Updates panel now shows version numbers (e.g. 1.0.0-beta.2) as the primary display, with commit SHAs as a secondary detail. + +### Fixed +- Controller restart time reduced from ~46 s to ~7 s by eliminating the graceful-stop hang. +- Projects canvas crash caused by malformed element payloads written by agents. +- Window move and resize jitter under rapid pointer events. + +## [1.0.0-beta.1] - 2026-06-09 + +Initial source-available public beta release under the taOS Sustainable Use License v0.1. diff --git a/desktop/package.json b/desktop/package.json index cc109b2f..31ff3b09 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "tinyagentos-desktop", "private": true, - "version": "1.0.0-beta", + "version": "1.0.0-beta.2", "type": "module", "scripts": { "dev": "vite", diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index b4805a80..2f34e9f2 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -283,7 +283,12 @@ export function App() { Return to fullscreen )} -
+ {/* Keep the entrance zoom (scale-95 -> none animates), but do NOT + retain a transform once launched: a `transform` on this ancestor + (even scale(1)) makes a containing block for every position:fixed + descendant, which pins the dock to the top and renders context + menus in the wrong corner. Steady state must be transform-free. */} +
diff --git a/desktop/src/apps/SettingsApp/UpdatesPanel.test.tsx b/desktop/src/apps/SettingsApp/UpdatesPanel.test.tsx index 42cbc80a..3dca9f78 100644 --- a/desktop/src/apps/SettingsApp/UpdatesPanel.test.tsx +++ b/desktop/src/apps/SettingsApp/UpdatesPanel.test.tsx @@ -7,7 +7,8 @@ const jResp = (b: any) => Promise.resolve({ ok: true, json: async () => b } as a beforeEach(() => { global.fetch = vi.fn(async (url: string) => { if (url === "/api/preferences/auto-update") return jResp({ check_enabled: true }); - if (url === "/api/settings/update-check") return jResp({ has_updates: false, current_commit: "abc x" }); + if (url === "/api/settings/update-check") + return jResp({ has_updates: false, current_version: "1.0.0-beta.2", current_commit: "abc x" }); if (url === "/api/settings/update-status") return jResp({ current_sha: "abc", pending_restart_sha: null }); if (url === "/api/settings/branches") return jResp({ branches: ["master", "dev"], current: "dev" }); if (url === "/api/settings/update-channel") return jResp({ status: "switching", branch: "master" }); @@ -15,6 +16,32 @@ beforeEach(() => { }) as any; }); +describe("UpdatesPanel — version display", () => { + it("shows current version prominently when up to date", async () => { + render(); + await waitFor(() => expect(screen.getByText("1.0.0-beta.2")).toBeInTheDocument()); + }); + + it("shows available version when an update is present", async () => { + (global.fetch as any) = vi.fn(async (url: string) => { + if (url === "/api/preferences/auto-update") return jResp({ check_enabled: true }); + if (url === "/api/settings/update-check") + return jResp({ + has_updates: true, + current_version: "1.0.0-beta.2", + new_version: "1.0.0-beta.3", + current_commit: "abc defg", + new_commit: "xyz uvwx", + }); + if (url === "/api/settings/update-status") return jResp({ current_sha: "abc", pending_restart_sha: null }); + return jResp({}); + }); + render(); + await waitFor(() => expect(screen.getByText("1.0.0-beta.2")).toBeInTheDocument()); + await waitFor(() => expect(screen.getByText("1.0.0-beta.3")).toBeInTheDocument()); + }); +}); + describe("UpdatesPanel — branch selector", () => { it("hides the branch selector until Advanced is expanded", async () => { render(); diff --git a/desktop/src/apps/SettingsApp/UpdatesPanel.tsx b/desktop/src/apps/SettingsApp/UpdatesPanel.tsx index b432c0e0..ec87b819 100644 --- a/desktop/src/apps/SettingsApp/UpdatesPanel.tsx +++ b/desktop/src/apps/SettingsApp/UpdatesPanel.tsx @@ -6,6 +6,7 @@ import { RestartProgressModal } from "@/apps/SettingsApp/_shared"; interface UpdateInfo { has_updates: boolean; current_version: string; + new_version?: string | null; current_commit: string; new_commit?: string | null; } @@ -234,17 +235,31 @@ export function UpdatesPanel() {

taOS

{info?.has_updates && info.new_commit ? (
-

- installed {info.current_commit} +

+ installed + {info.current_version} + {info.new_version ? ( + <> + + {info.new_version} + + ) : null}

-

- available {info.new_commit} +

+ {info.current_commit} → {info.new_commit}

) : ( -

- {info?.current_commit ?? "v0.1.0-dev"} -

+
+

+ {info?.current_version ?? "unknown"} +

+ {info?.current_commit ? ( +

+ {info.current_commit} +

+ ) : null} +
)}
{info?.has_updates && ( diff --git a/desktop/src/components/ContextMenu.tsx b/desktop/src/components/ContextMenu.tsx index e540c279..98e11611 100644 --- a/desktop/src/components/ContextMenu.tsx +++ b/desktop/src/components/ContextMenu.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; import { useIsMobile } from "@/hooks/use-is-mobile"; export interface MenuItem { @@ -103,7 +104,11 @@ export function ContextMenu({ x, y, items, onClose }: Props) { // Roving tabindex: track which navigable (non-separator, non-disabled) item is active let navigableCounter = -1; - return ( + // Portal to so the menu is never trapped inside a transformed ancestor + // (e.g. the dock's -translate-x-1/2). A transform on any ancestor makes a + // containing block for position:fixed, which would offset the menu away from + // the cursor. Rendered on body, its fixed coordinates are viewport-relative. + return createPortal(
); })} -
+
, + document.body, ); } diff --git a/desktop/src/components/DockIcon.tsx b/desktop/src/components/DockIcon.tsx index fd12ef9d..edef1257 100644 --- a/desktop/src/components/DockIcon.tsx +++ b/desktop/src/components/DockIcon.tsx @@ -20,6 +20,7 @@ export function DockIcon({ appId, isRunning, onClick }: Props) { const restoreWindow = useProcessStore((s) => s.restoreWindow); const minimizeWindow = useProcessStore((s) => s.minimizeWindow); const maximizeWindow = useProcessStore((s) => s.maximizeWindow); + const recenterWindow = useProcessStore((s) => s.recenterWindow); const closeWindow = useProcessStore((s) => s.closeWindow); const pinned = useDockStore((s) => s.pinned); const pin = useDockStore((s) => s.pin); @@ -56,6 +57,11 @@ export function DockIcon({ appId, isRunning, onClick }: Props) { icon: , action: () => maximizeWindow(win.id), }, + { + label: "Center Window", + icon: , + action: () => recenterWindow(win.id), + }, { label: "", separator: true }, isPinned ? { label: "Remove from Dock", icon: , action: () => unpin(appId) } diff --git a/desktop/src/components/Window.tsx b/desktop/src/components/Window.tsx index 69e8f71a..60a8be39 100644 --- a/desktop/src/components/Window.tsx +++ b/desktop/src/components/Window.tsx @@ -1,4 +1,4 @@ -import { useCallback, useRef, useState } from "react"; +import { memo, useCallback, useRef, useState } from "react"; import { Rnd } from "react-rnd"; import { motion, useReducedMotion } from "motion/react"; import { useProcessStore, type WindowState, type SnapPosition } from "@/stores/process-store"; @@ -13,9 +13,20 @@ interface Props { onDragStop: () => SnapPosition; } -export function Window({ win, onDrag, onDragStop }: Props) { - const { focusWindow, closeWindow, removeWindow, minimizeWindow, maximizeWindow, updatePosition, updateSize, snapWindow } = - useProcessStore(); +function WindowImpl({ win, onDrag, onDragStop }: Props) { + // Select each action individually. Destructuring useProcessStore() with no + // selector subscribes the window to EVERY store change, so any unrelated + // store write would re-render it mid-drag and react-rnd would reset its + // controlled position. Action references are stable, so these never trigger + // a re-render on their own. + const focusWindow = useProcessStore((s) => s.focusWindow); + const closeWindow = useProcessStore((s) => s.closeWindow); + const removeWindow = useProcessStore((s) => s.removeWindow); + const minimizeWindow = useProcessStore((s) => s.minimizeWindow); + const maximizeWindow = useProcessStore((s) => s.maximizeWindow); + const updatePosition = useProcessStore((s) => s.updatePosition); + const updateBounds = useProcessStore((s) => s.updateBounds); + const snapWindow = useProcessStore((s) => s.snapWindow); const app = getApp(win.appId); const preSnapRef = useRef<{ x: number; y: number; w: number; h: number } | null>(null); const isMobile = useIsMobile(); @@ -95,12 +106,37 @@ export function Window({ win, onDrag, onDragStop }: Props) { [onDragStop, snapWindow, updatePosition, win.id, win.size], ); + // Feed react-rnd's live position+size back every resize tick. react-rnd's + // position prop is controlled, and resizing from a top/left edge changes the + // position; without live feedback react-rnd's own internal re-render re-reads + // the stale stored position and the window jumps sideways mid-resize. Keeping + // the controlled props in lockstep with react-rnd's reported bounds keeps the + // resize smooth from every edge. + const handleResize = useCallback( + ( + _e: unknown, + _dir: unknown, + ref: HTMLElement, + _delta: unknown, + position: { x: number; y: number }, + ) => { + updateBounds(win.id, position.x, position.y, ref.offsetWidth, ref.offsetHeight); + }, + [updateBounds, win.id], + ); + const handleResizeStop = useCallback( - (_e: unknown, _dir: unknown, ref: HTMLElement) => { + ( + _e: unknown, + _dir: unknown, + ref: HTMLElement, + _delta: unknown, + position: { x: number; y: number }, + ) => { setDragging(false); - updateSize(win.id, ref.offsetWidth, ref.offsetHeight); + updateBounds(win.id, position.x, position.y, ref.offsetWidth, ref.offsetHeight); }, - [updateSize, win.id], + [updateBounds, win.id], ); const minSize = app?.minSize ?? { w: 300, h: 200 }; @@ -142,6 +178,7 @@ export function Window({ win, onDrag, onDragStop }: Props) { onDrag={handleDrag} onDragStop={handleDragStop} onResizeStart={() => setDragging(true)} + onResize={handleResize} onResizeStop={handleResizeStop} onMouseDown={() => focusWindow(win.id)} bounds="parent" @@ -248,3 +285,9 @@ export function Window({ win, onDrag, onDragStop }: Props) { ); } + +// Memoized so unrelated desktop re-renders (snap-zone preview, the live +// wallpaper, the agent command stream) do not re-render every window during a +// drag. Props are stable: `win` only changes when this window's own state +// changes, and onDrag/onDragStop are stabilized in useSnapZones. +export const Window = memo(WindowImpl); diff --git a/desktop/src/components/__tests__/ContextMenu.test.tsx b/desktop/src/components/__tests__/ContextMenu.test.tsx index cdb409c8..b0c171de 100644 --- a/desktop/src/components/__tests__/ContextMenu.test.tsx +++ b/desktop/src/components/__tests__/ContextMenu.test.tsx @@ -30,8 +30,8 @@ describe("ContextMenu keyboard navigation", () => { }); it("ArrowDown moves focus to next enabled item", () => { - const { container } = renderMenu(); - const menu = container.firstChild as HTMLElement; + renderMenu(); + const menu = screen.getByRole("menu"); fireEvent.keyDown(menu, { key: "ArrowDown" }); const items = screen.getAllByRole("menuitem"); // Skip disabled "Delete", so ArrowDown from Copy → Paste @@ -39,8 +39,8 @@ describe("ContextMenu keyboard navigation", () => { }); it("ArrowDown wraps from last to first enabled item", () => { - const { container } = renderMenu(); - const menu = container.firstChild as HTMLElement; + renderMenu(); + const menu = screen.getByRole("menu"); // Navigate to last enabled item (Rename) fireEvent.keyDown(menu, { key: "End" }); fireEvent.keyDown(menu, { key: "ArrowDown" }); @@ -49,8 +49,8 @@ describe("ContextMenu keyboard navigation", () => { }); it("ArrowUp moves focus to previous enabled item", () => { - const { container } = renderMenu(); - const menu = container.firstChild as HTMLElement; + renderMenu(); + const menu = screen.getByRole("menu"); fireEvent.keyDown(menu, { key: "ArrowDown" }); // Paste fireEvent.keyDown(menu, { key: "ArrowUp" }); // back to Copy const items = screen.getAllByRole("menuitem"); @@ -58,8 +58,8 @@ describe("ContextMenu keyboard navigation", () => { }); it("Home moves focus to first enabled item", () => { - const { container } = renderMenu(); - const menu = container.firstChild as HTMLElement; + renderMenu(); + const menu = screen.getByRole("menu"); fireEvent.keyDown(menu, { key: "ArrowDown" }); fireEvent.keyDown(menu, { key: "Home" }); const items = screen.getAllByRole("menuitem"); @@ -67,8 +67,8 @@ describe("ContextMenu keyboard navigation", () => { }); it("End moves focus to last enabled item", () => { - const { container } = renderMenu(); - const menu = container.firstChild as HTMLElement; + renderMenu(); + const menu = screen.getByRole("menu"); fireEvent.keyDown(menu, { key: "End" }); // Rename is last enabled (Delete is disabled) const items = screen.getAllByRole("menuitem"); @@ -77,15 +77,15 @@ describe("ContextMenu keyboard navigation", () => { it("Escape calls onClose", () => { const onClose = vi.fn(); - const { container } = renderMenu(onClose); - const menu = container.firstChild as HTMLElement; + renderMenu(onClose); + const menu = screen.getByRole("menu"); fireEvent.keyDown(menu, { key: "Escape" }); expect(onClose).toHaveBeenCalled(); }); it("disabled item is skipped by arrow navigation", () => { - const { container } = renderMenu(); - const menu = container.firstChild as HTMLElement; + renderMenu(); + const menu = screen.getByRole("menu"); // ArrowDown from Copy → Paste, ArrowDown again → Rename (skipping disabled Delete) fireEvent.keyDown(menu, { key: "ArrowDown" }); // Paste fireEvent.keyDown(menu, { key: "ArrowDown" }); // Rename (skips Delete) @@ -106,8 +106,8 @@ describe("ContextMenu keyboard navigation", () => { { label: "Copy", action: vi.fn(), disabled: true }, { label: "Paste", action: vi.fn(), disabled: true }, ]; - const { container } = render(); - const menu = container.firstChild as HTMLElement; + render(); + const menu = screen.getByRole("menu"); // None of these should throw or attempt to focus undefined expect(() => fireEvent.keyDown(menu, { key: "ArrowDown" })).not.toThrow(); expect(() => fireEvent.keyDown(menu, { key: "ArrowUp" })).not.toThrow(); @@ -115,8 +115,9 @@ describe("ContextMenu keyboard navigation", () => { expect(() => fireEvent.keyDown(menu, { key: "End" })).not.toThrow(); // Escape still calls onClose even with all disabled const onClose = vi.fn(); - const { container: c2 } = render(); - fireEvent.keyDown(c2.firstChild as HTMLElement, { key: "Escape" }); + render(); + const menus = screen.getAllByRole("menu"); + fireEvent.keyDown(menus[menus.length - 1], { key: "Escape" }); expect(onClose).toHaveBeenCalled(); }); @@ -160,8 +161,8 @@ describe("ContextMenu keyboard navigation", () => { }); it("ArrowDown when focus is outside list moves to first enabled item", () => { - const { container } = renderMenu(); - const menu = container.firstChild as HTMLElement; + renderMenu(); + const menu = screen.getByRole("menu"); // Move focus to a focusable element outside the menu const outside = document.createElement("button"); document.body.appendChild(outside); @@ -174,8 +175,8 @@ describe("ContextMenu keyboard navigation", () => { }); it("ArrowUp when focus is outside list moves to last enabled item", () => { - const { container } = renderMenu(); - const menu = container.firstChild as HTMLElement; + renderMenu(); + const menu = screen.getByRole("menu"); const outside = document.createElement("button"); document.body.appendChild(outside); outside.focus(); diff --git a/desktop/src/hooks/use-snap-zones.ts b/desktop/src/hooks/use-snap-zones.ts index 54a6d474..00ec4053 100644 --- a/desktop/src/hooks/use-snap-zones.ts +++ b/desktop/src/hooks/use-snap-zones.ts @@ -1,4 +1,4 @@ -import { useCallback, useState } from "react"; +import { useCallback, useRef, useState } from "react"; import type { SnapPosition } from "@/stores/process-store"; const EDGE_THRESHOLD = 16; @@ -55,16 +55,28 @@ export function getSnapBounds(snap: SnapPosition, vp: Viewport): { x: number; y: export function useSnapZones(viewport: Viewport) { const [preview, setPreview] = useState(null); + // Refs so onDrag/onDragStop keep a STABLE identity across renders. react-rnd's + // position prop is controlled, so a Window re-render mid-drag re-applies the + // stored position and yanks the window back ("jumping"). Stable callbacks let + // be memoized and skip those re-renders while a drag is in flight. + const previewRef = useRef(null); + const viewportRef = useRef(viewport); + viewportRef.current = viewport; const onDrag = useCallback((x: number, y: number) => { - setPreview(detectSnapZone(x, y, viewport)); - }, [viewport]); + const zone = detectSnapZone(x, y, viewportRef.current); + previewRef.current = zone; + // Only flip state when the zone actually changes, so crossing the same zone + // repeatedly does not re-render the desktop on every pointer move. + setPreview((prev) => (prev === zone ? prev : zone)); + }, []); - const onDragStop = useCallback(() => { - const result = preview; + const onDragStop = useCallback((): SnapPosition => { + const result = previewRef.current; + previewRef.current = null; setPreview(null); return result; - }, [preview]); + }, []); return { preview, previewBounds: getSnapBounds(preview, viewport), onDrag, onDragStop }; } diff --git a/desktop/src/stores/process-store.ts b/desktop/src/stores/process-store.ts index af979a6e..5cf0aaca 100644 --- a/desktop/src/stores/process-store.ts +++ b/desktop/src/stores/process-store.ts @@ -35,12 +35,50 @@ interface ProcessStore { minimizeWindow: (id: string) => void; restoreWindow: (id: string) => void; maximizeWindow: (id: string) => void; + recenterWindow: (id: string) => void; updatePosition: (id: string, x: number, y: number) => void; updateSize: (id: string, w: number, h: number) => void; + updateBounds: (id: string, x: number, y: number, w: number, h: number) => void; snapWindow: (id: string, snap: SnapPosition) => void; runningAppIds: () => string[]; } +// Compute on-screen-safe bounds for a window. Used when restoring or +// un-maximizing so a window that drifted off-screen (or is larger than the +// current desktop) comes back at a usable size, recentered if it is no longer +// reachable. The desktop area sits below the 32px top bar and above the ~84px +// dock reservation, matching Window.tsx. Idempotent for windows already +// comfortably on-screen. Pass a far-off position to force a recenter. +function safeBounds( + position: { x: number; y: number }, + size: { w: number; h: number }, +): { position: { x: number; y: number }; size: { w: number; h: number } } { + const topBarH = 32; + const dockH = 84; + const margin = 16; + const vw = typeof window !== "undefined" ? window.innerWidth : 1280; + const vh = typeof window !== "undefined" ? window.innerHeight : 800; + const deskW = vw; + const deskH = vh - topBarH - dockH; + + // Never larger than the desktop (minus margins); never below a usable minimum. + const w = Math.max(300, Math.min(size.w, deskW - margin * 2)); + const h = Math.max(200, Math.min(size.h, deskH - margin * 2)); + + let { x, y } = position; + // "Reachable" means enough of the title bar is on the desktop to grab it. + const GRAB = 80; + const reachable = x <= deskW - GRAB && x + w >= GRAB && y >= 0 && y <= deskH - topBarH; + if (!reachable) { + x = Math.round((deskW - w) / 2); + y = Math.round((deskH - h) / 2); + } + // Final clamp keeps even a reachable-but-oversized window fully in view. + x = Math.max(margin, Math.min(x, Math.max(margin, deskW - w - margin))); + y = Math.max(0, Math.min(y, Math.max(0, deskH - h - margin))); + return { position: { x, y }, size: { w, h } }; +} + let idCounter = 0; export const useProcessStore = create((set, get) => ({ @@ -123,20 +161,45 @@ export const useProcessStore = create((set, get) => ({ restoreWindow(id) { const z = get().nextZIndex; set((s) => ({ - windows: s.windows.map((w) => - w.id === id - ? { ...w, minimized: false, focused: true, zIndex: z } - : { ...w, focused: false } - ), + windows: s.windows.map((w) => { + if (w.id !== id) return { ...w, focused: false }; + // Showing a window again: if it drifted off-screen while hidden, pull + // it back into view. A maximized window keeps its stored bounds for + // when it is later un-maximized. + const safe = w.maximized ? {} : safeBounds(w.position, w.size); + return { ...w, ...safe, minimized: false, focused: true, zIndex: z }; + }), nextZIndex: z + 1, })); }, maximizeWindow(id) { set((s) => ({ - windows: s.windows.map((w) => - w.id === id ? { ...w, maximized: !w.maximized } : w - ), + windows: s.windows.map((w) => { + if (w.id !== id) return w; + if (!w.maximized) { + // Maximizing implies showing the window, even from a minimized state. + return { ...w, maximized: true, minimized: false }; + } + // Un-maximizing: make sure the restored bounds are on-screen. + const safe = safeBounds(w.position, w.size); + return { ...w, ...safe, maximized: false }; + }), + })); + }, + + recenterWindow(id) { + const z = get().nextZIndex; + set((s) => ({ + windows: s.windows.map((w) => { + if (w.id !== id) return { ...w, focused: false }; + // Force a recenter (far-off position guarantees safeBounds recenters), + // and ensure the window is shown and not maximized so the user can see + // and move it. The recovery path for a window lost off-screen. + const safe = safeBounds({ x: -1e6, y: -1e6 }, w.size); + return { ...w, ...safe, minimized: false, maximized: false, focused: true, zIndex: z }; + }), + nextZIndex: z + 1, })); }, @@ -156,6 +219,18 @@ export const useProcessStore = create((set, get) => ({ })); }, + // Position and size in ONE update. A resize from a top/left edge moves the + // window's x/y as well as its w/h; committing them separately renders one + // frame with the new size but the old position, which reads as a jump. This + // applies both atomically. + updateBounds(id, x, y, w, h) { + set((s) => ({ + windows: s.windows.map((win) => + win.id === id ? { ...win, position: { x, y }, size: { w, h } } : win + ), + })); + }, + snapWindow(id, snap) { set((s) => ({ windows: s.windows.map((w) => diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 00000000..97941f04 --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,52 @@ +# Release process + +taOS uses semver beta: `1.0.0-beta.N`, incremented on every dev->master promotion. + +## Steps + +### 1. Bump version + +Update the version string to the next `1.0.0-beta.N` in exactly these three files (keep them identical): + +- `pyproject.toml` line `version = "..."` +- `desktop/package.json` line `"version": "..."` +- `tinyagentos/__init__.py` line `__version__ = "..."` + +### 2. Update CHANGELOG.md + +Move the items under `## [Unreleased]` into a new dated section at the top: + +``` +## [1.0.0-beta.N] - YYYY-MM-DD +``` + +Group bullets under `Added`, `Changed`, and `Fixed`. Keep each bullet one concise line. +Leave `## [Unreleased]` empty and ready for the next cycle. + +### 3. Open a PR to dev + +Commit the version bump and changelog update together. Open a PR targeting `dev`. +CI runs the backend pytest suite and frontend vitest on every PR; both must be green before merging. + +### 4. Promote dev to master + +Once the PR is merged to `dev`, open a follow-up PR from `dev` to `master`. +After that PR merges, the install-count telemetry at taos.my starts recording the new version for every fresh install. + +### 5. Tag and create a GitHub Release + +On `master`, after the merge commit: + +``` +git tag v1.0.0-beta.N +git push origin v1.0.0-beta.N +``` + +Create a GitHub Release for that tag. Paste the matching CHANGELOG section as the release body. +The taos.my changelog page pulls from GitHub Releases, so this is the canonical public record. + +## Notes + +- The install-count ping reports the installed version per device, so each release bump gives per-build telemetry without any extra work. +- Never tag on `dev`; tags always land on `master` after promotion. +- Hotfixes follow the same steps: bump, changelog, PR to dev, promote, tag. diff --git a/docs/STATUS.md b/docs/STATUS.md index 32a08d01..c835bba3 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,5 +1,11 @@ SINGLE SOURCE OF TRUTH for cross-agent handoff. -Last updated: 2026-06-16 ~12:40 BST, @taOS (ACTIVE). +Last updated: 2026-06-16 ~14:25 BST, @taOS (ACTIVE). + +▶▶ LATEST-10 2026-06-16 ~14:25 BST, dev=d66ec15c, master=1fb8f000, Pi LIVE on fix/window-drag-jitter (7b13a0a1): the window-fix branch now ALSO carries OFF-SCREEN WINDOW RECOVERY (Jay was locked out: windows dragged/resized off-screen with no way back). safeBounds() recomputes the desktop area and recenters a window on restore / un-maximize when its title bar is no longer reachable; maximize now un-minimizes (so Maximise always shows it); new recenterWindow action + a "Center Window" item in the dock right-click menu as the direct recovery affordance. This is ON TOP of the move + resize jitter fixes (LATEST-9). Deployed to the Pi, tsc clean, window/store tests pass, for Jay to confirm move + resize + recovery before promoting. CORRECTION to LATEST-8/9: dependabot #953 (cryptography 46.0.7->48.0.1) merged to MASTER, not dev (dependabot PRs target the default branch). master=1fb8f000 has the crypto patch; DEV DOES NOT yet -> back-merge master->dev at the next promotion. New dependabot #954 (uv group, 2 updates) open vs master. OPEN DEV PRs (green-pending): #955 mail security fix-forward (gitar #952 findings: validate IMAP UID before FETCH, reject CRLF header injection in to/cc/subject, fix IPv6 _strip_port); #956 versioning+changelog (bump 1.0.0-beta.2 across the 3 version files, Settings Updates shows VERSION numbers not commit SHAs, backend stops hardcoding current_version 0.1.0 + adds new_version, CHANGELOG.md + docs/RELEASING.md). #81 Phase 1 still needs a real build pass (the subagent only planned it). NEXT: Jay confirms window move/resize/recovery -> PR fix/window-drag-jitter to dev -> merge #955 + #956 -> promote dev->master (back-merging master first for the crypto patch), tag v1.0.0-beta.2 + GitHub Release. + +▶▶ LATEST-9 2026-06-16 ~14:00 BST, dev=963535ce, master=98e459f9, Pi LIVE on fix/window-drag-jitter (848e8588): WINDOW MOVE + RESIZE JITTER FIX deployed to the Pi for Jay to confirm BEFORE promoting. TWO distinct bugs, both from react-rnd CONTROLLED position/size props. (1) MOVE jitter ("jumping around like crazy"): any desktop re-render mid-drag re-applied the stored position and yanked the window back; the snap-zone preview (fired every pointer move), the live particle wallpaper, and the agent command stream all re-render the desktop continuously. Fix: stabilize the snap-zone onDrag/onDragStop callbacks via refs + only flip preview when the zone changes (use-snap-zones.ts); memoize Window + subscribe to process-store ACTIONS individually instead of the whole store (Window.tsx). (2) RESIZE jump (Jay: "horizontally jumps right, vertically jumps left during resize"): the resize handler saved only the new SIZE and ignored react-rnd's position arg, so a top/left-edge resize moved x/y but then re-applied the stale stored position. Fix: feed react-rnd's live position+size back via onResize, commit both atomically with a new updateBounds store action, persist final bounds on resize stop (Window.tsx + process-store.ts). 4 files total, no dep change, tsc clean, window+store+hook tests pass. Pi was on an old tip (f31a2eb7); this deploy also brought it up to current dev (mail/browser/optional-apps/canvas-fix). NEXT: on Jay's confirm of BOTH move and resize -> PR fix/window-drag-jitter to dev -> promote dev->master, then Pi back to dev. ALSO: #81 Phase 1 subagent produced a PLAN only (docs/superpowers/plans/2026-06-16-app-project-integration-p1.md), did NOT build it yet; needs a build pass. dependabot #953 (cryptography 46.0.7->48.0.1, the flagged high-sev alert) open + gate-clean, merge on green. tldraw stays as-is until #75. + +▶▶ LATEST-8 2026-06-16 ~13:30 BST, master=98e459f9 (PROMOTED), dev=d1c68dcd: PROMOTED dev->master via #952 (admin-merged past a flaky Kilo sandbox check; Gitar + all real CI green, NOT --delete-branch). Master now ships Mail (#945), Browser redesign + taos.my homepage (#944/#947), optional Store apps (#946), agent screenshot (#948), Projects redesign (#941), notification wiring (#939/#940), the restart-hang fix (ec724624), and the canvas crash fix (#951). BRANCH HYGIENE: enabled GitHub auto-delete-head-branches on merge (stops the pile-up at source); pruned ~20 stale local tracking refs; origin actually has 87 branches (the ~500 local count was a 2nd remote `hognek` fork + stale refs); 34 merged-into-dev origin branches are deletable on a nod. taos.my: Umami analytics snippet added DIRECTLY to all 3 site pages (jaylfc/taos-website PR #6 merged to main; self-hosted janlabs.co.uk instance, cookieless; do NOT also set UMAMI_* env or it double-loads). tldraw STAYS AS-IS until #75 replaces it (Jay: keep the watermark, no license buy; the Konva rebuild carries the custom look). #81 APP<->PROJECTS INTEGRATION: design decided + spec at docs/superpowers/specs/2026-06-16-app-project-integration-design.md. Jay's forks: per-window project pinning (each window pins its own project, null=Workspace); Workspace is the playground (no-project outputs route to type-organized workspace folders Images/Documents/Audio/...); outputs NEST in the pinned project's files// folder. Verified: user workspace = data_dir/workspace/ (Images Studio already writes under it); projects have files/ already. Phase 1 (backend resolve_output_dir helper + per-window projectId in process-store + window-chrome project switcher + Images Studio as the reference producer) BUILDING NOW. Canva product study + 8 reference screenshots in .design/research/canva/ (informs Design Studio + #81). NEXT: land #81 Phase 1; then pick the first studio build (recommend #75 canvas->Konva as the shared spine). ▶▶ LATEST-7 2026-06-16 ~12:40 BST, dev=79af4b04, Pi still on the pre-fix build (deploy held, Jay mid-flow): CANVAS CRASH ROOT CAUSE FOUND + FIXED (#951 MERGED). Jay lifted the no-Playwright rule for canvas only ("use playwright to fix canvas"); reproduced live on the Pi: the backend validates only an element's kind (the mermaid kind got 422) NOT its payload, but tldraw custom-shape props are strict, so an agent note/link/image with a missing field / wrong type (font_size:"14") / empty payload throws "ValidationError: ... Expected number, got undefined/a string" -> the element silently vanishes (pre-#949 it crashed the whole board). FIX: elementToShape now coerces every payload field + geometry + author_kind to its schema type with sane defaults so imperfect agent writes render instead of disappearing; 13 unit tests, gitar APPROVED no issues, all CI green. #950 (matchMedia scope) also merged since LATEST-6. TLDRAW REPLACEMENT DECISION: tldraw is proprietary ($6k/yr, the "Get a license for production" watermark, a redistribution clause that fits poorly with taOS being source-available) -> replace it. Research done -> migrate the canvas to Konva/react-konva + perfect-freehand (MIT), which becomes the SHARED engine for canvas + Design Studio + Presentations (task #75, do first). NEW JAY VISION (all OPTIONAL Store apps, NOT preinstalled; offline; agent-authorable; MIT/Apache only): local tasks #76 Design Studio (Canva-like, Konva), #77 Web Studio (agent-writes-Astro/HTML + Puck/GrapesJS editor + Caddy-LXC LAN-only hosting; agent NEVER touches host firewall/root, a closed-outcome broker provisions/publishes, public exposure is consent + Pro-gated via the unbuilt relay), #78 Music Studio (Tone.js + signal UI + JSON/MIDI model; ACE-Step audio + Anticipatory-MT/Magenta symbolic, all Apache; AVOID MusicGen CC-BY-NC weights), #79 Office Suite (Write TipTap / Calc Fortune-sheet+formulajs+SheetJS / Database Glide-grid over taOS backend / Presentations slide-JSON+Konva+PptxGenJS; AVOID HyperFormula/Handsontable/NocoDB/Teable/PPTist), #80 Guided Mode (OS-wide: agent highlights the UI element + narrates + steps the user through the full flow to TEACH rather than just do; builds on the desktop-control API + screenshot round-trip). Full license-vetted build-off research lives in the private roadmap doc (kept out of public issues per no-public-competitor-refs). NEXT (needs Jay greenlight): pick the first build from #75-80 (I recommend #75 canvas->Konva as the shared spine); decide the tldraw watermark interim (keep vs buy a license until the migration lands). STILL HELD: #73 single-port browser proxy over Tailscale. diff --git a/pyproject.toml b/pyproject.toml index 4e29f60b..7762a291 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "tinyagentos" -version = "1.0.0-beta" +version = "1.0.0-beta.2" description = "Self-hosted AI agent memory system for low-power hardware" license = { file = "LICENSE" } requires-python = ">=3.11" diff --git a/tests/test_mail_security.py b/tests/test_mail_security.py new file mode 100644 index 00000000..7f3ba1e4 --- /dev/null +++ b/tests/test_mail_security.py @@ -0,0 +1,62 @@ +"""Security validation for untrusted mail inputs and the CSP host helper.""" + +import pytest + +from tinyagentos import mail_client +from tinyagentos.middleware.security_headers import _strip_port + + +class TestValidateUid: + def test_accepts_plain_numeric_uid(self): + assert mail_client._validate_uid("12345") == "12345" + + @pytest.mark.parametrize( + "bad", + [ + "", + "1 (RFC822)", + "1\r\nA OK", + "1:5", + "abc", + "1,2,3", + "*", + ], + ) + def test_rejects_non_numeric_or_injection(self, bad): + with pytest.raises(mail_client.MailValidationError): + mail_client._validate_uid(bad) + + +class TestValidateHeader: + def test_accepts_clean_value(self): + assert mail_client._validate_header("Hello there", "subject") == "Hello there" + assert mail_client._validate_header("a@b.test, c@d.test", "to") == "a@b.test, c@d.test" + + @pytest.mark.parametrize( + "bad", + [ + "subject\r\nBcc: victim@evil.test", + "name\nX-Injected: 1", + "value\rwith-cr", + "has\x00null", + ], + ) + def test_rejects_crlf_injection(self, bad): + with pytest.raises(mail_client.MailValidationError): + mail_client._validate_header(bad, "to") + + +class TestStripPort: + @pytest.mark.parametrize( + "host,expected", + [ + ("example.com", "example.com"), + ("example.com:6969", "example.com"), + ("192.168.1.5:443", "192.168.1.5"), + ("[::1]", "[::1]"), + ("[::1]:6969", "[::1]"), + ("[2001:db8::1]:8080", "[2001:db8::1]"), + ], + ) + def test_strips_port_without_corrupting_ipv6(self, host, expected): + assert _strip_port(host) == expected diff --git a/tests/test_routes_settings.py b/tests/test_routes_settings.py index 27ff0a2c..0bdd9c5b 100644 --- a/tests/test_routes_settings.py +++ b/tests/test_routes_settings.py @@ -265,6 +265,45 @@ async def test_timeout_kills_subprocess_no_orphan(self): ) +class TestUpdateCheckVersion: + """update-check endpoint must return the installed version from tinyagentos.__version__.""" + + @pytest.mark.asyncio + async def test_current_version_matches_package(self, client): + """current_version in the response must equal tinyagentos.__version__.""" + import asyncio + from unittest.mock import AsyncMock, MagicMock, patch + + import tinyagentos + + fake_proc = MagicMock() + fake_proc.returncode = 0 + fake_proc.communicate = AsyncMock(return_value=(b"", b"")) + + async def fake_rev_parse(*_args, **_kwargs): + p = MagicMock() + p.communicate = AsyncMock(return_value=(b"abc123\n", b"")) + return p + + with ( + patch( + "tinyagentos.routes.settings.asyncio.create_subprocess_exec", + side_effect=fake_rev_parse, + ), + patch( + "tinyagentos.auto_update.remote_is_strictly_ahead", + new=AsyncMock(return_value=False), + ), + ): + resp = await client.get("/api/settings/update-check") + + assert resp.status_code == 200 + data = resp.json() + assert data["current_version"] == tinyagentos.__version__, ( + f"Expected {tinyagentos.__version__!r} but got {data['current_version']!r}" + ) + + class TestRebuildResultStructured: """Issue #327: rebuild_desktop_bundle_if_stale returns a structured RebuildResult so callers don't have to string-match the message field.""" diff --git a/tinyagentos/__init__.py b/tinyagentos/__init__.py index b63a7ea8..80256cd3 100644 --- a/tinyagentos/__init__.py +++ b/tinyagentos/__init__.py @@ -1 +1 @@ -__version__ = "1.0.0-beta" +__version__ = "1.0.0-beta.2" diff --git a/tinyagentos/mail_client.py b/tinyagentos/mail_client.py index 1a7212a2..d6f5bda8 100644 --- a/tinyagentos/mail_client.py +++ b/tinyagentos/mail_client.py @@ -14,6 +14,7 @@ import asyncio import email import imaplib +import re import smtplib from dataclasses import dataclass, field from email.header import decode_header, make_header @@ -33,7 +34,11 @@ MAX_MESSAGE_LIMIT = 200 -class MailFolderError(ValueError): +class MailValidationError(ValueError): + """Raised when an untrusted value is unsafe to use in a mail command.""" + + +class MailFolderError(MailValidationError): """Raised when a folder name is unsafe to interpolate into an IMAP command.""" @@ -49,6 +54,32 @@ def _validate_folder(folder: str) -> str: return folder +_UID_RE = re.compile(r"^[0-9]+$") + + +def _validate_uid(uid: str) -> str: + """Reject message ids that are not a plain IMAP UID. + + ``uid`` is an untrusted path parameter passed straight to + ``conn.uid("FETCH", uid, ...)``. A non-numeric value could carry extra IMAP + command tokens, so we require a bare numeric UID. The ids we hand out from + ``list_messages`` are always numeric UIDs, so this rejects nothing legitimate.""" + if not uid or not _UID_RE.fullmatch(uid): + raise MailValidationError(f"invalid message id: {uid!r}") + return uid + + +def _validate_header(value: str, field: str) -> str: + """Reject CR/LF/NUL in a value destined for a MIME header. + + ``to``/``cc``/``subject`` come from the client and are assigned directly to + message headers. A newline would let a caller inject extra headers (a hidden + Bcc, a spoofed From), so we forbid the line-break characters outright.""" + if any(c in value for c in ("\r", "\n", "\x00")): + raise MailValidationError(f"invalid characters in {field}") + return value + + @dataclass class MailAccountConfig: """Connection details for a single account. The password is resolved from @@ -282,6 +313,7 @@ def _get_message_blocking( cfg: MailAccountConfig, folder: str, uid: str ) -> MessageDetail | None: _validate_folder(folder) + _validate_uid(uid) conn = _imap_connect(cfg) try: conn.select(f'"{folder}"', readonly=True) @@ -306,10 +338,10 @@ def _build_outgoing( ) -> MIMEMultipart: msg = MIMEMultipart() msg["From"] = cfg.email_address or cfg.username - msg["To"] = to + msg["To"] = _validate_header(to, "to") if cc: - msg["Cc"] = cc - msg["Subject"] = subject + msg["Cc"] = _validate_header(cc, "cc") + msg["Subject"] = _validate_header(subject, "subject") msg.attach(MIMEText(body, "plain")) for filename, content, content_type in attachments or []: maintype, _, subtype = content_type.partition("/") diff --git a/tinyagentos/middleware/security_headers.py b/tinyagentos/middleware/security_headers.py index 39553f15..60070fb3 100644 --- a/tinyagentos/middleware/security_headers.py +++ b/tinyagentos/middleware/security_headers.py @@ -33,6 +33,12 @@ def _build_csp(frame_src_extra: str = "") -> str: def _strip_port(host: str) -> str: + # A bracketed IPv6 host ("[::1]" or "[::1]:6969") is full of colons, so a + # naive rsplit on ":" would corrupt it. Keep everything up to the closing + # bracket; for a normal "host:port" just drop the trailing port. + if host.startswith("["): + end = host.find("]") + return host[: end + 1] if end != -1 else host return host.rsplit(":", 1)[0] if ":" in host else host diff --git a/tinyagentos/routes/mail.py b/tinyagentos/routes/mail.py index 4cb7d629..a882e8f9 100644 --- a/tinyagentos/routes/mail.py +++ b/tinyagentos/routes/mail.py @@ -211,7 +211,7 @@ async def get_message( return JSONResponse({"error": "account credential missing"}, status_code=400) try: detail = await mail_client.get_message(cfg, folder, uid) - except mail_client.MailFolderError as exc: + except mail_client.MailValidationError as exc: return JSONResponse({"error": str(exc)}, status_code=400) except Exception as exc: return JSONResponse({"error": f"imap error: {exc}"}, status_code=502) @@ -240,6 +240,8 @@ async def send( # TODO(phase-2): attachment upload pass-through (multipart) -- the # client supports it; the route only sends a text body for now. await mail_client.send_message(cfg, body.to, body.subject, body.body, cc=body.cc) + except mail_client.MailValidationError as exc: + return JSONResponse({"error": str(exc)}, status_code=400) except Exception as exc: return JSONResponse({"error": f"smtp error: {exc}"}, status_code=502) return {"status": "sent"} diff --git a/tinyagentos/routes/settings.py b/tinyagentos/routes/settings.py index 6fa6c74d..4c9250bb 100644 --- a/tinyagentos/routes/settings.py +++ b/tinyagentos/routes/settings.py @@ -500,6 +500,8 @@ async def set_container_runtime(request: Request): async def check_for_updates(request: Request): """Check if a newer version of TinyAgentOS is available on GitHub.""" import asyncio + import re + from tinyagentos import __version__ from tinyagentos.auto_update import remote_is_strictly_ahead project_dir = str(Path(__file__).parent.parent.parent) @@ -548,9 +550,26 @@ async def _log1(ref: str) -> str: current = await _log1("HEAD") new_commit = await _log1(f"origin/{branch}") if has_updates else None + # Read the version string from the remote branch HEAD so the UI can show + # the target version number without requiring a full install first. + new_version: str | None = None + if has_updates: + try: + rc, raw = await _run_capture( + ["git", "show", f"origin/{branch}:tinyagentos/__init__.py"], + cwd=project_dir, + ) + if rc == 0 and raw: + m = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', raw) + if m: + new_version = m.group(1) + except Exception: + pass + return { "has_updates": has_updates, - "current_version": "0.1.0", + "current_version": __version__, + "new_version": new_version, "current_commit": current, "new_commit": new_commit, } diff --git a/uv.lock b/uv.lock index 5f5a3cae..90c7faac 100644 --- a/uv.lock +++ b/uv.lock @@ -3481,7 +3481,7 @@ wheels = [ [[package]] name = "tinyagentos" -version = "1.0.0b0" +version = "1.0.0b2" source = { editable = "." } dependencies = [ { name = "aiosqlite" },