From 4d7171cebe8af9e6fff0d97b0c13e94a1d6571f6 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Tue, 21 Jul 2026 22:11:24 +0100 Subject: [PATCH 001/116] fix(desktop): dialogs render above windows, and mint shows the URL and PIN (#2092) Two bugs on the same screen, both reported from live use. Window z-index was an unbounded counter: every open, focus, restore and recenter incremented nextZIndex forever, while portal overlays sit at a fixed z-[10001]. After enough focus switches in one session, windows rendered on top of modal dialogs. The stack is now renumbered to 1..N on each change, so window z stays far below the overlay layer regardless of session length, and relative order is preserved by sorting on the existing values first. This affected every portal overlay, not only the invite dialog. ProjectMembers closed the invite dialog in its onMinted handler, unmounting it before the result rendered. The invite URL and PIN are shown exactly once and cannot be recovered, so a successful mint looked like a silent failure. The parent now refreshes its member list only and the user closes the dialog once they have copied the credentials. --- .../src/apps/ProjectsApp/ProjectMembers.tsx | 7 ++- .../__tests__/ProjectMembers.invite.test.tsx | 32 ++++++++++++ .../__tests__/process-store-zindex.test.ts | 50 ++++++++++++++++++ desktop/src/stores/process-store.ts | 51 ++++++++++++++----- 4 files changed, 125 insertions(+), 15 deletions(-) create mode 100644 desktop/src/apps/ProjectsApp/__tests__/ProjectMembers.invite.test.tsx create mode 100644 desktop/src/stores/__tests__/process-store-zindex.test.ts diff --git a/desktop/src/apps/ProjectsApp/ProjectMembers.tsx b/desktop/src/apps/ProjectsApp/ProjectMembers.tsx index c9c3d3db1..ec91b143e 100644 --- a/desktop/src/apps/ProjectsApp/ProjectMembers.tsx +++ b/desktop/src/apps/ProjectsApp/ProjectMembers.tsx @@ -571,7 +571,12 @@ export function ProjectMembers({ project, onChanged }: { project: Project; onCha projectId={project.id} onClose={() => setInviteOpen(false)} onMinted={() => { - setInviteOpen(false); + // Do NOT close the dialog here. The mint succeeds and the dialog + // then renders the invite URL and PIN, which are shown exactly + // once and cannot be recovered afterwards. Closing on mint threw + // that away and the user saw a dialog that vanished on success + // (reported 2026-07-21). Refresh the member list only; the user + // closes the dialog themselves once they have copied the details. onChanged(); }} /> diff --git a/desktop/src/apps/ProjectsApp/__tests__/ProjectMembers.invite.test.tsx b/desktop/src/apps/ProjectsApp/__tests__/ProjectMembers.invite.test.tsx new file mode 100644 index 000000000..e3582ed6c --- /dev/null +++ b/desktop/src/apps/ProjectsApp/__tests__/ProjectMembers.invite.test.tsx @@ -0,0 +1,32 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { InviteAgentDialog } from "../InviteAgentDialog"; + +// The invite URL and PIN are shown EXACTLY ONCE and cannot be recovered. +// ProjectMembers used to close the dialog in its onMinted handler, unmounting +// it before the credentials rendered, so a successful mint looked like a +// silent failure (reported 2026-07-21). +describe("mint result survives onMinted", () => { + it("renders the invite URL and PIN after a successful mint", async () => { + const mint = { invite_id: "123456", pin: "4321", expires_ts: Date.now() / 1000 + 3600, scopes: [] }; + vi.stubGlobal("fetch", vi.fn(async (url: string, init?: RequestInit) => { + if (init?.method === "POST") { + return { ok: true, json: async () => mint } as Response; + } + return { ok: true, json: async () => [] } as Response; + })); + + // onMinted must NOT unmount the dialog: the parent only refreshes its list. + const onMinted = vi.fn(); + render( {}} onMinted={onMinted} />); + + fireEvent.click(screen.getByRole("button", { name: /mint invite/i })); + + await waitFor(() => { + expect(screen.getByText("4321")).toBeInTheDocument(); + }); + expect(onMinted).toHaveBeenCalled(); + expect(screen.getByLabelText(/invite result/i)).toBeInTheDocument(); + vi.unstubAllGlobals(); + }); +}); diff --git a/desktop/src/stores/__tests__/process-store-zindex.test.ts b/desktop/src/stores/__tests__/process-store-zindex.test.ts new file mode 100644 index 000000000..818363878 --- /dev/null +++ b/desktop/src/stores/__tests__/process-store-zindex.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { useProcessStore } from "../process-store"; + +// Overlays (dialogs, menus) portal to document.body at a FIXED z such as +// z-[10001]. Window z must therefore stay bounded, or a long session pushes +// windows above modals. Reported 2026-07-21: the invite dialog rendered behind +// the Projects window. +const OVERLAY_Z = 10001; + +describe("window z-index stays below the overlay layer", () => { + beforeEach(() => { + useProcessStore.setState({ windows: [], nextZIndex: 1 }); + }); + + it("does not grow z without bound as windows are focused", () => { + const s = useProcessStore.getState(); + const a = s.openWindow("files"); + const b = s.openWindow("chat"); + + // Simulate a long session: thousands of focus switches. + for (let i = 0; i < 5000; i++) { + useProcessStore.getState().focusWindow(i % 2 === 0 ? a : b); + } + + const maxZ = Math.max(...useProcessStore.getState().windows.map((w) => w.zIndex)); + expect(maxZ).toBeLessThan(OVERLAY_Z); + // With two windows the stack is 1..2 regardless of focus count. + expect(maxZ).toBeLessThanOrEqual(2); + }); + + it("preserves relative stacking order after normalisation", () => { + const s = useProcessStore.getState(); + const a = s.openWindow("files"); + const b = s.openWindow("chat"); + useProcessStore.getState().focusWindow(a); + + const wins = useProcessStore.getState().windows; + const za = wins.find((w) => w.id === a)!.zIndex; + const zb = wins.find((w) => w.id === b)!.zIndex; + // The most recently focused window is on top. + expect(za).toBeGreaterThan(zb); + }); + + it("keeps z bounded as many windows open", () => { + const s = useProcessStore.getState(); + for (let i = 0; i < 40; i++) s.openWindow(`app-${i}`); + const maxZ = Math.max(...useProcessStore.getState().windows.map((w) => w.zIndex)); + expect(maxZ).toBeLessThan(OVERLAY_Z); + }); +}); diff --git a/desktop/src/stores/process-store.ts b/desktop/src/stores/process-store.ts index 9efadd232..c44d0ccf6 100644 --- a/desktop/src/stores/process-store.ts +++ b/desktop/src/stores/process-store.ts @@ -82,6 +82,25 @@ function safeBounds( let idCounter = 0; +// Window z-indexes are a bounded stacking ORDER, not an ever-growing counter. +// +// Every open, focus, restore and recenter used to increment `nextZIndex` +// forever. Portal overlays (dialogs, menus) sit at a FIXED z such as +// `z-[10001]`, so once a long session pushed the window counter past that +// constant, windows rendered ON TOP of modal dialogs. Reported 2026-07-21: +// the invite dialog appeared behind the Projects window after a day of use. +// +// Renumbering the stack to 1..N after each change keeps window z far below the +// overlay layer no matter how long the session runs, and preserves relative +// order because the existing values are sorted first. +function normaliseStack(windows: WindowState[]): WindowState[] { + const rank = new Map(); + [...windows] + .sort((a, b) => a.zIndex - b.zIndex) + .forEach((w, i) => rank.set(w.id, i + 1)); + return windows.map((w) => ({ ...w, zIndex: rank.get(w.id) ?? 1 })); +} + export const useProcessStore = create((set, get) => ({ windows: [], nextZIndex: 1, @@ -123,8 +142,10 @@ export const useProcessStore = create((set, get) => ({ launchNonce: 0, }; set((s) => ({ - windows: s.windows.map((w) => ({ ...w, focused: false })).concat(win), - nextZIndex: z + 1, + windows: normaliseStack( + s.windows.map((w) => ({ ...w, focused: false })).concat(win), + ), + nextZIndex: s.windows.length + 2, })); return id; }, @@ -147,12 +168,14 @@ export const useProcessStore = create((set, get) => ({ focusWindow(id) { const z = get().nextZIndex; set((s) => ({ - windows: s.windows.map((w) => ({ - ...w, - focused: w.id === id, - zIndex: w.id === id ? z : w.zIndex, - })), - nextZIndex: z + 1, + windows: normaliseStack( + s.windows.map((w) => ({ + ...w, + focused: w.id === id, + zIndex: w.id === id ? z : w.zIndex, + })), + ), + nextZIndex: s.windows.length + 1, })); }, @@ -167,15 +190,15 @@ export const useProcessStore = create((set, get) => ({ restoreWindow(id) { const z = get().nextZIndex; set((s) => ({ - windows: s.windows.map((w) => { + windows: normaliseStack(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, + })), + nextZIndex: s.windows.length + 1, })); }, @@ -197,15 +220,15 @@ export const useProcessStore = create((set, get) => ({ recenterWindow(id) { const z = get().nextZIndex; set((s) => ({ - windows: s.windows.map((w) => { + windows: normaliseStack(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, + })), + nextZIndex: s.windows.length + 1, })); }, From f769049263fdb0876b6d3285a6b8c32f5709256c Mon Sep 17 00:00:00 2001 From: jaylfc Date: Tue, 21 Jul 2026 22:45:44 +0100 Subject: [PATCH 002/116] feat(agents): project_tasks_create scope so an external agent can author cards (#2098) An agent holding project_tasks could claim, close and comment on existing cards but never open one, so an approved grant bought nothing on that route. Rather than widen project_tasks, which is documented and tested as read plus lifecycle plus comments (Invariant 2 + 5) and would retroactively grant authoring to every agent already approved for it, authoring gets its own narrower scope that an owner opts into per agent. create_task now authorises through the same _authorize_task_actor as the other task routes, parameterised on scope, so existence-hiding 404s behave identically. The middleware allowlist admits POST .../tasks, which lets the token reach a handler that then verifies JWT, project binding and the narrower scope; project_tasks alone is still refused. Tests keep the original invariant (project_tasks alone cannot create) and add the halves that make it meaningful: the new scope DOES allow authoring, it is project-bound so a grant on A cannot create on B, and it does not widen member management. Authorship is attributed to the agent, not the project owner. --- .../apps/ProjectsApp/InviteAgentDialog.tsx | 1 + tests/test_routes_projects_agent_tasks.py | 56 ++++++++++++++++++- tinyagentos/auth_middleware.py | 10 +++- tinyagentos/routes/agent_auth_requests.py | 7 +++ tinyagentos/routes/agent_registry.py | 1 + tinyagentos/routes/projects.py | 35 ++++++++---- 6 files changed, 97 insertions(+), 13 deletions(-) diff --git a/desktop/src/apps/ProjectsApp/InviteAgentDialog.tsx b/desktop/src/apps/ProjectsApp/InviteAgentDialog.tsx index 367b75256..3a8a309f0 100644 --- a/desktop/src/apps/ProjectsApp/InviteAgentDialog.tsx +++ b/desktop/src/apps/ProjectsApp/InviteAgentDialog.tsx @@ -3,6 +3,7 @@ import { createPortal } from "react-dom"; const SCOPE_PRESETS: { value: string; label: string; defaultOn: boolean; disabled?: boolean; hint?: string }[] = [ { value: "project_tasks", label: "project_tasks", defaultOn: true, disabled: true, hint: "required for project invites" }, + { value: "project_tasks_create", label: "project_tasks_create", defaultOn: false, hint: "author NEW cards. project_tasks alone is read, lifecycle and comments only" }, { value: "canvas_read", label: "canvas_read", defaultOn: true }, { value: "canvas_write", label: "canvas_write", defaultOn: true }, ]; diff --git a/tests/test_routes_projects_agent_tasks.py b/tests/test_routes_projects_agent_tasks.py index 3fe840292..924aeb761 100644 --- a/tests/test_routes_projects_agent_tasks.py +++ b/tests/test_routes_projects_agent_tasks.py @@ -297,7 +297,10 @@ class TestExcludedRoutes: """Invariant 2 + 5: project_tasks must NOT reach create-task, members, or project-lifecycle routes; the token authenticates nothing off the allowlist.""" - async def test_cannot_create_task(self, ctx): + async def test_project_tasks_alone_cannot_create_task(self, ctx): + """project_tasks is read + lifecycle + comments. Authoring requires the + SEPARATE project_tasks_create grant, so an agent approved only for + project_tasks must still be refused (Invariant 2 + 5 preserved).""" pid = await _new_project(ctx, "alpha") _cid, token = await _mint_agent(ctx, pid) async with _bare(ctx.app) as bare: @@ -516,3 +519,54 @@ async def test_session_owner_marks_claimable(self, ctx): ) assert resp.status_code == 200, resp.text assert "claimable" in resp.json()["labels"] + +@pytest.mark.asyncio +class TestTaskCreationScope: + """project_tasks_create is a separate, narrower grant for AUTHORING cards. + + Both halves matter: an agent WITH it can create, an agent WITHOUT it cannot. + A test that only asserted the refusal would pass against a route nobody can + reach, and one that only asserted success would not prove the scope is + enforced at all. + """ + + async def test_project_tasks_create_allows_authoring(self, ctx): + pid = await _new_project(ctx, "alpha") + cid, token = await _mint_agent(ctx, pid, scopes=("project_tasks_create",)) + async with _bare(ctx.app) as bare: + resp = await bare.post( + f"/api/projects/{pid}/tasks", + json={"title": "authored by an agent"}, + headers=_hdr(token), + ) + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["title"] == "authored by an agent" + # Authorship is attributed to the AGENT, not to the project owner. + assert body["created_by"] == cid + + async def test_create_scope_is_project_bound(self, ctx): + """A grant on project A must not authorise creation on project B.""" + pid_a = await _new_project(ctx, "alpha") + pid_b = await _new_project(ctx, "beta") + _cid, token = await _mint_agent(ctx, pid_a, scopes=("project_tasks_create",)) + async with _bare(ctx.app) as bare: + resp = await bare.post( + f"/api/projects/{pid_b}/tasks", + json={"title": "cross project"}, + headers=_hdr(token), + ) + # Existence-hiding: indistinguishable from a project that is not theirs. + assert resp.status_code == 404, resp.text + + async def test_create_scope_does_not_widen_other_routes(self, ctx): + """project_tasks_create authorises AUTHORING only, not member management.""" + pid = await _new_project(ctx, "alpha") + _cid, token = await _mint_agent(ctx, pid, scopes=("project_tasks_create",)) + async with _bare(ctx.app) as bare: + resp = await bare.post( + f"/api/projects/{pid}/members", + json={"mode": "native", "agent_id": "x"}, + headers=_hdr(token), + ) + assert resp.status_code in (401, 403, 404) diff --git a/tinyagentos/auth_middleware.py b/tinyagentos/auth_middleware.py index 35b2513b5..45870c5fe 100644 --- a/tinyagentos/auth_middleware.py +++ b/tinyagentos/auth_middleware.py @@ -42,14 +42,20 @@ # paths (/api/projects/{pid}/tasks...), so an exact frozenset can't match them; # a (method, compiled-regex) allowlist is used instead. Each pattern is fully # anchored and uses a slash-free segment ([^/]+) with an exact segment count, so -# sibling routes that must stay session-only -- POST .../tasks (create), -# /members, /relationships, /audit, /activity, project lifecycle -- never match. +# sibling routes that must stay session-only -- /members, /relationships, +# /audit, /activity, project lifecycle -- never match. POST .../tasks IS +# reachable, but only with project_tasks_create, never with project_tasks. # This is the project-scoped analogue of the exact _AGENT_TOKEN_PATHS contract: # the token only reaches the handler, which then verifies the JWT + grant + # project binding. Anything not listed here is NOT reachable by a registry JWT. _SEG = r"[^/]+" _AGENT_TASK_ROUTES = ( ("GET", re.compile(rf"^/api/projects/{_SEG}/tasks$")), + # Task CREATION, gated by the SEPARATE project_tasks_create scope (not + # project_tasks, which stays read + lifecycle + comments per Invariant 2+5). + # Reaching the handler is not authorisation: it then verifies the JWT, the + # project binding, and that narrower scope. + ("POST", re.compile(rf"^/api/projects/{_SEG}/tasks$")), ("GET", re.compile(rf"^/api/projects/{_SEG}/tasks/ready$")), ("GET", re.compile(rf"^/api/projects/{_SEG}/tasks/{_SEG}$")), ("GET", re.compile(rf"^/api/projects/tasks/{_SEG}/context$")), diff --git a/tinyagentos/routes/agent_auth_requests.py b/tinyagentos/routes/agent_auth_requests.py index 97bdd9f52..433000cbc 100644 --- a/tinyagentos/routes/agent_auth_requests.py +++ b/tinyagentos/routes/agent_auth_requests.py @@ -55,6 +55,13 @@ # agent's OWN project only (bound by the token's project_id claim). Does NOT # grant task create, member management, or project lifecycle. "project_tasks", + # Authoring cards, deliberately SEPARATE from project_tasks. project_tasks + # is documented and tested as read + lifecycle + comments only ("Invariant + # 2 + 5"), so widening it would retroactively grant authoring to every agent + # already approved for it, invisibly to whoever approved them. A distinct + # scope keeps an existing approval meaning exactly what it meant when it was + # given, and makes authoring an explicit per-agent opt-in. + "project_tasks_create", # Canvas access: read and write on a specific project's canvas. Like # project_tasks, a project_id is required so the token is bound to the # operator-validated project rather than whatever the unauthenticated agent diff --git a/tinyagentos/routes/agent_registry.py b/tinyagentos/routes/agent_registry.py index 3cff04787..e61a0c460 100644 --- a/tinyagentos/routes/agent_registry.py +++ b/tinyagentos/routes/agent_registry.py @@ -93,6 +93,7 @@ class OrgUpdateRequest(BaseModel): "files_read", "files_write", "tools_execute", "registry_feeds_read", "project_tasks", + "project_tasks_create", "canvas_read", "canvas_write", "decisions_read", "decisions_write", }) diff --git a/tinyagentos/routes/projects.py b/tinyagentos/routes/projects.py index a593c7a89..a2a66564e 100644 --- a/tinyagentos/routes/projects.py +++ b/tinyagentos/routes/projects.py @@ -452,11 +452,15 @@ async def _require_task_in_project( async def _authorize_task_actor( - request: Request, pstore, project_id: str + request: Request, pstore, project_id: str, scope: str = "project_tasks" ) -> "tuple[str, bool, dict] | JSONResponse": """Resolve the actor for a task route that accepts EITHER a session - owner/admin OR an approved external agent's registry JWT (scope - project_tasks) bound to THIS project. + owner/admin OR an approved external agent's registry JWT holding ``scope`` + (default ``project_tasks``) bound to THIS project. + + ``scope`` is a parameter because authoring uses a SEPARATE, narrower grant + (``project_tasks_create``): project_tasks is documented and tested as read + plus lifecycle plus comments, so creation must not ride on it. Returns ``(actor_id, is_agent, project)`` on success, or a JSONResponse to return directly. These routes take ``request: Request`` and auth INSIDE the @@ -482,7 +486,7 @@ async def _authorize_task_actor( try: caller = await check_agent_scope_for_project( - request, "project_tasks", project_id + request, scope, project_id ) except HTTPException as exc: if exc.status_code == 403 and exc.detail == PROJECT_SCOPE_MISMATCH_DETAIL: @@ -588,14 +592,25 @@ async def create_task( project_id: str, payload: CreateTaskIn, request: Request, - user: CurrentUser = Depends(current_user), ): + """Create a task as a session owner/admin, or as an approved external agent + holding ``project_tasks_create`` on THIS project. + + Authoring is a SEPARATE scope from ``project_tasks`` on purpose: that scope + is documented and tested as read + lifecycle + comments ("Invariant 2 + 5"), + so widening it would retroactively grant authoring to every agent already + approved for it. Existence-hiding 404 behaviour matches the other task + routes. + """ store = request.app.state.project_task_store pstore = request.app.state.project_store estore = request.app.state.project_element_store - project_or_err = await _get_owned_project(pstore, project_id, user) - if isinstance(project_or_err, JSONResponse): - return project_or_err + actor_or_err = await _authorize_task_actor( + request, pstore, project_id, scope="project_tasks_create" + ) + if isinstance(actor_or_err, JSONResponse): + return actor_or_err + actor_id, _is_agent, _project = actor_or_err if payload.parent_task_id is not None: parent = await store.get_task(payload.parent_task_id) if parent is None or parent["project_id"] != project_id: @@ -614,10 +629,10 @@ async def create_task( assignee_id=payload.assignee_id, parent_task_id=payload.parent_task_id, element_id=element_id, - created_by=user.user_id, + created_by=actor_id, ) _beads_mark_dirty(request, project_id) - await pstore.log_activity(project_id, user.user_id, "task.created", {"task_id": t["id"], "title": t["title"]}) + await pstore.log_activity(project_id, actor_id, "task.created", {"task_id": t["id"], "title": t["title"]}) return t From 39fc23f07f0f64543f8b42124b6add6f0e1fe9df Mon Sep 17 00:00:00 2001 From: jaylfc Date: Wed, 22 Jul 2026 12:27:52 +0100 Subject: [PATCH 003/116] feat(library): item card component with thumbnail, status, artifacts, collection link (#2097) Add LibraryItemCard component per docs/design/library-app.md sections 2-4. Card shows thumbnail (or placeholder), title, kind badge, media duration, pipeline status per stage (jobs shape), artifact list (text, transcript, description, ocr) with preview, link-to-collection action, and a disabled Download stub until P3. Failure states are always visible -- no silent empties for missing thumbnails, pipeline stages, artifacts, or errors. Includes lib/library.ts with types and API client for the library store (items, artifacts, jobs) and 25 component tests covering pending, processing, ready, and error states. --- .../src/components/LibraryItemCard.test.tsx | 397 ++++++++++++++++++ desktop/src/components/LibraryItemCard.tsx | 267 ++++++++++++ desktop/src/lib/library.ts | 150 +++++++ 3 files changed, 814 insertions(+) create mode 100644 desktop/src/components/LibraryItemCard.test.tsx create mode 100644 desktop/src/components/LibraryItemCard.tsx create mode 100644 desktop/src/lib/library.ts diff --git a/desktop/src/components/LibraryItemCard.test.tsx b/desktop/src/components/LibraryItemCard.test.tsx new file mode 100644 index 000000000..da1757600 --- /dev/null +++ b/desktop/src/components/LibraryItemCard.test.tsx @@ -0,0 +1,397 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { LibraryItemCard } from "./LibraryItemCard"; +import type { LibraryItem, LibraryArtifact, LibraryJob } from "@/lib/library"; + +/* ------------------------------------------------------------------ */ +/* Fixtures */ +/* ------------------------------------------------------------------ */ + +function makeItem(overrides: Partial = {}): LibraryItem { + return { + id: "item-1", + kind: "text", + source_url: "", + title: "Test Item", + status: "pending", + storage_path: "", + bytes: 0, + meta_json: "{}", + created_at: 1000, + updated_at: 1000, + ...overrides, + }; +} + +function makeArtifact(overrides: Partial = {}): LibraryArtifact { + return { + id: "art-1", + item_id: "item-1", + kind: "text", + path: "/tmp/test.txt", + meta_json: "{}", + created_at: 1000, + ...overrides, + }; +} + +function makeJob(overrides: Partial = {}): LibraryJob { + return { + id: "job-1", + item_id: "item-1", + stage: "metadata", + state: "queued", + error: "", + created_at: 1000, + updated_at: 1000, + ...overrides, + }; +} + +/* ------------------------------------------------------------------ */ +/* Tests */ +/* ------------------------------------------------------------------ */ + +describe("LibraryItemCard", () => { + /* ---------------------------------------------------------------- */ + /* Pending */ + /* ---------------------------------------------------------------- */ + + describe("pending state", () => { + it("renders the title, kind badge, and pending status", () => { + render(); + expect(screen.getByText("My Video")).toBeInTheDocument(); + expect(screen.getByText("YouTube")).toBeInTheDocument(); + expect(screen.getByText("pending")).toBeInTheDocument(); + }); + + it("shows a thumbnail placeholder when no thumbnail artifact exists", () => { + render(); + expect(screen.getByText("No thumbnail")).toBeInTheDocument(); + expect(screen.queryByAltText(/Thumbnail/)).not.toBeInTheDocument(); + }); + + it("shows the no-pipeline-stages message", () => { + render(); + expect(screen.getByText("No pipeline stages")).toBeInTheDocument(); + }); + + it("shows the no-artifacts message", () => { + render(); + expect(screen.getByText("No artifacts")).toBeInTheDocument(); + }); + + it("disables the download button", () => { + render(); + const downloadBtn = screen.getByLabelText("Download (not available yet)"); + expect(downloadBtn).toBeDisabled(); + }); + + it("renders the link-to-collection button", () => { + render(); + expect(screen.getByLabelText("Link to collection")).toBeInTheDocument(); + }); + }); + + /* ---------------------------------------------------------------- */ + /* Processing */ + /* ---------------------------------------------------------------- */ + + describe("processing state", () => { + const processingItem = makeItem({ + id: "item-2", + kind: "text", + title: "notes.txt", + status: "processing", + bytes: 2048, + meta_json: JSON.stringify({}), + }); + const processingJobs = [ + makeJob({ id: "job-a", item_id: "item-2", stage: "metadata", state: "done" }), + makeJob({ id: "job-b", item_id: "item-2", stage: "text", state: "processing" }), + ]; + + it("renders the processing status", () => { + render( + , + ); + expect(screen.getByText("processing")).toBeInTheDocument(); + }); + + it("shows pipeline stages with their states", () => { + render( + , + ); + expect(screen.getByText("metadata: done")).toBeInTheDocument(); + expect(screen.getByText("text: processing")).toBeInTheDocument(); + }); + + it("disables the download button", () => { + render( + , + ); + expect(screen.getByLabelText("Download (not available yet)")).toBeDisabled(); + }); + }); + + /* ---------------------------------------------------------------- */ + /* Done (ready) */ + /* ---------------------------------------------------------------- */ + + describe("done (ready) state", () => { + const doneItem = makeItem({ + id: "item-3", + kind: "text", + title: "notes.txt", + status: "ready", + bytes: 1024, + meta_json: JSON.stringify({ + preview: "This is the preview text of the document content.", + duration: 120, + }), + }); + const doneArtifacts = [ + makeArtifact({ + id: "art-t", + item_id: "item-3", + kind: "thumbnail", + path: "/tmp/thumb.jpg", + meta_json: JSON.stringify({ width: 320, height: 240 }), + }), + makeArtifact({ + id: "art-x", + item_id: "item-3", + kind: "text", + path: "/tmp/notes.txt", + meta_json: JSON.stringify({ char_count: 1024, line_count: 20 }), + }), + ]; + const doneJobs = [ + makeJob({ id: "job-a", item_id: "item-3", stage: "metadata", state: "done" }), + makeJob({ id: "job-b", item_id: "item-3", stage: "text", state: "done" }), + ]; + + it("renders the ready status", () => { + render( + , + ); + expect(screen.getByText("ready")).toBeInTheDocument(); + }); + + it("renders the thumbnail image", () => { + render( + , + ); + const img = screen.getByAltText("Thumbnail for notes.txt") as HTMLImageElement; + expect(img).toBeInTheDocument(); + expect(img.src).toContain("/tmp/thumb.jpg"); + }); + + it("shows the duration for media items", () => { + render( + , + ); + expect(screen.getByText("2:00")).toBeInTheDocument(); + }); + + it("shows artifacts with their preview text", () => { + render( + , + ); + expect(screen.getByText("text")).toBeInTheDocument(); + expect(screen.getByText("1024 chars")).toBeInTheDocument(); + expect( + screen.getByText("This is the preview text of the document content."), + ).toBeInTheDocument(); + }); + + it("shows pipeline stages as done", () => { + render( + , + ); + expect(screen.getByText("metadata: done")).toBeInTheDocument(); + expect(screen.getByText("text: done")).toBeInTheDocument(); + }); + + it("calls onLinkToCollection when the link button is clicked", () => { + const onLinkToCollection = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByLabelText("Link to collection")); + expect(onLinkToCollection).toHaveBeenCalledTimes(1); + expect(onLinkToCollection).toHaveBeenCalledWith(doneItem); + }); + + it("disables the download button", () => { + render( + , + ); + expect(screen.getByLabelText("Download (not available yet)")).toBeDisabled(); + }); + + it("shows the source URL button when source_url is set", () => { + const youtubeItem = makeItem({ + id: "item-yt", + kind: "url:youtube", + title: "YouTube Video", + status: "ready", + source_url: "https://youtube.com/watch?v=abc", + meta_json: JSON.stringify({}), + }); + render(); + expect(screen.getByLabelText("Open source https://youtube.com/watch?v=abc")).toBeInTheDocument(); + }); + }); + + /* ---------------------------------------------------------------- */ + /* Failed (error) */ + /* ---------------------------------------------------------------- */ + + describe("failed (error) state", () => { + const failedItem = makeItem({ + id: "item-4", + kind: "pdf", + title: "broken.pdf", + status: "error", + bytes: 512, + meta_json: JSON.stringify({ + error: "Source file not found: /tmp/broken.pdf", + }), + }); + const failedJobs = [ + makeJob({ + id: "job-a", + item_id: "item-4", + stage: "metadata", + state: "failed", + error: "Source file not found", + }), + ]; + + it("renders the error status", () => { + render( + , + ); + expect(screen.getByText("error")).toBeInTheDocument(); + }); + + it("shows the error message in an alert", () => { + render( + , + ); + const alert = screen.getByRole("alert"); + expect(alert).toHaveTextContent("Source file not found: /tmp/broken.pdf"); + }); + + it("shows the failed pipeline stage", () => { + render( + , + ); + expect(screen.getByText("metadata: failed")).toBeInTheDocument(); + }); + + it("disables the download button", () => { + render( + , + ); + expect(screen.getByLabelText("Download (not available yet)")).toBeDisabled(); + }); + + it("shows a default error message when meta_json has no error field", () => { + const itemNoError = makeItem({ + id: "item-5", + status: "error", + meta_json: "{}", + }); + render(); + const alert = screen.getByRole("alert"); + expect(alert).toHaveTextContent("Processing failed"); + }); + }); + + /* ---------------------------------------------------------------- */ + /* Edge cases */ + /* ---------------------------------------------------------------- */ + + describe("edge cases", () => { + it("renders Untitled when title is empty", () => { + render(); + expect(screen.getByText("Untitled")).toBeInTheDocument(); + }); + + it("shows no preview available when item has no preview in meta_json", () => { + const item = makeItem({ + status: "ready", + meta_json: JSON.stringify({}), + }); + const artifacts = [makeArtifact({ kind: "text" })]; + render(); + expect(screen.getByText("No preview available")).toBeInTheDocument(); + }); + + it("renders transcript and ocr artifacts in the list", () => { + const item = makeItem({ + status: "ready", + meta_json: JSON.stringify({ preview: "preview text" }), + }); + const artifacts = [ + makeArtifact({ id: "a1", kind: "transcript" }), + makeArtifact({ id: "a2", kind: "ocr" }), + ]; + render(); + expect(screen.getByText("transcript")).toBeInTheDocument(); + expect(screen.getByText("ocr")).toBeInTheDocument(); + }); + }); +}); diff --git a/desktop/src/components/LibraryItemCard.tsx b/desktop/src/components/LibraryItemCard.tsx new file mode 100644 index 000000000..b1dccc1a9 --- /dev/null +++ b/desktop/src/components/LibraryItemCard.tsx @@ -0,0 +1,267 @@ +import { Download, Link, AlertCircle, Image as ImageIcon, ExternalLink } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui"; +import type { LibraryItem, LibraryArtifact, LibraryJob } from "@/lib/library"; + +/* ------------------------------------------------------------------ */ +/* Constants */ +/* ------------------------------------------------------------------ */ + +const KIND_LABELS: Record = { + text: "Text", + pdf: "PDF", + image: "Image", + archive: "Archive", + file: "File", + "url:youtube": "YouTube", + "url:web": "Web", +}; + +const STATUS_COLORS: Record = { + pending: "bg-amber-500/15 text-amber-400 border-amber-500/30", + processing: "bg-amber-500/15 text-amber-400 border-amber-500/30", + ready: "bg-green-500/15 text-green-400 border-green-500/30", + error: "bg-red-500/15 text-red-400 border-red-500/30", +}; + +const JOB_STATE_COLORS: Record = { + queued: "bg-white/10 text-shell-text-tertiary border-white/10", + processing: "bg-amber-500/15 text-amber-400 border-amber-500/30", + done: "bg-green-500/15 text-green-400 border-green-500/30", + failed: "bg-red-500/15 text-red-400 border-red-500/30", +}; + +const TEXT_ARTIFACT_KINDS = ["text", "transcript", "description", "ocr"]; + +/* ------------------------------------------------------------------ */ +/* Helpers */ +/* ------------------------------------------------------------------ */ + +function parseMeta(metaJson: string): Record { + try { + return JSON.parse(metaJson || "{}") as Record; + } catch { + return {}; + } +} + +function formatDuration(seconds: number): string { + if (!seconds || seconds <= 0) return ""; + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = Math.floor(seconds % 60); + if (h > 0) return `${h}:${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`; + return `${m}:${s.toString().padStart(2, "0")}`; +} + +function formatBytes(bytes: number): string { + if (!bytes || bytes <= 0) return "0 B"; + const units = ["B", "KB", "MB", "GB", "TB"]; + const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); + return `${(bytes / Math.pow(1024, i)).toFixed(i === 0 ? 0 : 1)} ${units[i]}`; +} + +function kindLabel(kind: string): string { + return KIND_LABELS[kind] ?? kind; +} + +function statusColor(status: string): string { + return STATUS_COLORS[status] ?? STATUS_COLORS.pending ?? ""; +} + +function jobStateColor(state: string): string { + return JOB_STATE_COLORS[state] ?? JOB_STATE_COLORS.queued ?? ""; +} + +/* ------------------------------------------------------------------ */ +/* Component */ +/* ------------------------------------------------------------------ */ + +export interface LibraryItemCardProps { + item: LibraryItem; + artifacts?: LibraryArtifact[]; + jobs?: LibraryJob[]; + onLinkToCollection?: (item: LibraryItem) => void; + onDownload?: (item: LibraryItem) => void; + onOpenSource?: (item: LibraryItem) => void; +} + +export function LibraryItemCard({ + item, + artifacts = [], + jobs = [], + onLinkToCollection, + onDownload, + onOpenSource, +}: LibraryItemCardProps) { + const itemMeta = parseMeta(item.meta_json); + const duration = itemMeta.duration ? Number(itemMeta.duration) : 0; + const durationStr = formatDuration(duration); + const preview = itemMeta.preview ? String(itemMeta.preview) : null; + const error = + item.status === "error" + ? (String(itemMeta.error || "") || "Processing failed") + : null; + + const thumbnailArtifact = artifacts.find((a) => a.kind === "thumbnail"); + const textArtifacts = artifacts.filter((a) => TEXT_ARTIFACT_KINDS.includes(a.kind)); + + return ( + + +
+ + {item.title || "Untitled"} + + + {item.status} + +
+ + + {kindLabel(item.kind)} + + {durationStr && {durationStr}} + {formatBytes(item.bytes)} + {item.source_url && ( + + )} + +
+ + + {/* Thumbnail */} + {thumbnailArtifact ? ( + {`Thumbnail { + const target = e.target as HTMLImageElement; + target.style.display = "none"; + }} + /> + ) : ( +
+ + No thumbnail +
+ )} + + {/* Error state -- failure must be visible, never silent */} + {error && ( +
+ + {error} +
+ )} + + {/* Pipeline stages (jobs shape from spec section 2) */} + {jobs.length > 0 ? ( +
+
+ Pipeline +
+
+ {jobs.map((job) => ( + + {job.stage}: {job.state} + + ))} +
+
+ ) : ( +
+ No pipeline stages +
+ )} + + {/* Artifacts with preview */} + {textArtifacts.length > 0 ? ( +
+
+ Artifacts +
+ {textArtifacts.map((art) => { + const artMeta = parseMeta(art.meta_json); + const charCount = artMeta.char_count + ? `${Number(artMeta.char_count)} chars` + : ""; + return ( +
+
+ + {art.kind} + + {charCount && ( + {charCount} + )} +
+ {preview ? ( +

+ {preview} +

+ ) : ( +

+ No preview available +

+ )} +
+ ); + })} +
+ ) : ( +
+ No artifacts +
+ )} + + {/* Actions */} +
+ + +
+
+
+ ); +} diff --git a/desktop/src/lib/library.ts b/desktop/src/lib/library.ts new file mode 100644 index 000000000..5755ba268 --- /dev/null +++ b/desktop/src/lib/library.ts @@ -0,0 +1,150 @@ +/* ------------------------------------------------------------------ */ +/* Types */ +/* ------------------------------------------------------------------ */ + +export type LibraryItemStatus = "pending" | "processing" | "ready" | "error"; + +export interface LibraryItem { + id: string; + kind: string; + source_url: string; + title: string; + status: LibraryItemStatus; + storage_path: string; + bytes: number; + meta_json: string; + created_at: number; + updated_at: number; +} + +export interface LibraryArtifact { + id: string; + item_id: string; + kind: string; + path: string; + meta_json: string; + created_at: number; +} + +export interface LibraryJob { + id: string; + item_id: string; + stage: string; + state: string; + error: string; + created_at: number; + updated_at: number; +} + +export interface LibraryItemDetail { + item: LibraryItem; + artifacts: LibraryArtifact[]; + jobs: LibraryJob[]; +} + +/* ------------------------------------------------------------------ */ +/* Helpers */ +/* ------------------------------------------------------------------ */ + +async function fetchJson(url: string, fallback: T, init?: RequestInit): Promise { + try { + const res = await fetch(url, { ...init, headers: { Accept: "application/json", ...init?.headers } }); + if (!res.ok) return fallback; + const ct = res.headers.get("content-type") ?? ""; + if (!ct.includes("application/json")) return fallback; + return await res.json(); + } catch { + return fallback; + } +} + +/* ------------------------------------------------------------------ */ +/* Items */ +/* ------------------------------------------------------------------ */ + +export interface ListLibraryItemsParams { + kind?: string; + status?: string; + limit?: number; + offset?: number; +} + +export async function listLibraryItems( + params?: ListLibraryItemsParams, +): Promise<{ items: LibraryItem[]; count: number }> { + const qs = new URLSearchParams(); + if (params?.kind) qs.set("kind", params.kind); + if (params?.status) qs.set("status", params.status); + if (params?.limit != null) qs.set("limit", String(params.limit)); + if (params?.offset != null) qs.set("offset", String(params.offset)); + const query = qs.toString(); + const url = `/api/library/items${query ? `?${query}` : ""}`; + const data = await fetchJson<{ items: LibraryItem[]; count: number }>(url, { items: [], count: 0 }); + return { items: Array.isArray(data.items) ? data.items : [], count: data.count ?? 0 }; +} + +export async function getLibraryItem(itemId: string): Promise { + try { + const res = await fetch(`/api/library/items/${encodeURIComponent(itemId)}`, { + headers: { Accept: "application/json" }, + }); + if (!res.ok) return null; + const ct = res.headers.get("content-type") ?? ""; + if (!ct.includes("application/json")) return null; + return await res.json(); + } catch { + return null; + } +} + +export async function deleteLibraryItem(itemId: string): Promise { + try { + const res = await fetch(`/api/library/items/${encodeURIComponent(itemId)}`, { + method: "DELETE", + headers: { Accept: "application/json" }, + }); + return res.ok; + } catch { + return false; + } +} + +export async function reprocessLibraryItem(itemId: string): Promise { + try { + const res = await fetch(`/api/library/items/${encodeURIComponent(itemId)}/reprocess`, { + method: "POST", + headers: { Accept: "application/json" }, + }); + return res.ok; + } catch { + return false; + } +} + +/* ------------------------------------------------------------------ */ +/* Ingest */ +/* ------------------------------------------------------------------ */ + +export interface IngestLibraryOptions { + title?: string; + source?: string; +} + +export async function ingestLibraryUrl( + url: string, + opts?: IngestLibraryOptions, +): Promise<{ item_id: string; status: string } | null> { + try { + const res = await fetch("/api/library/ingest", { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify({ url, title: opts?.title ?? "" }), + }); + if (!res.ok) return null; + const ct = res.headers.get("content-type") ?? ""; + if (!ct.includes("application/json")) return null; + return await res.json(); + } catch { + return null; + } +} From f3e983cc92e642dbc5f5e106cd411c8c822b905a Mon Sep 17 00:00:00 2001 From: jaylfc Date: Wed, 22 Jul 2026 13:08:32 +0100 Subject: [PATCH 004/116] feat(agents): enforce files_read/files_write so member agents can access project Files (#2100) * feat(agents): enforce files_read/files_write scopes so member agents can access project Files Project-files routes (/api/projects/{slug}/files*, mkdir, trash, stats) had no membership or scope gate and were absent from the agent middleware allowlist, so an agent token could not reach them at all while the files_read/files_write scopes existed but were never enforced. This wires them up, mirroring the canvas pattern: - _authorize_files_actor resolves slug -> project and authorizes a session owner/admin (unchanged) OR an agent holding files_read (reads) / files_write (writes) grant bound to that project. A token bound to another project, or an unknown slug, collapses into an existence-hiding 404; a missing scope is 403. - _AGENT_FILES_ROUTES added to auth_middleware so agent JWTs pass through to the routes, which verify the grant. - InviteAgentDialog offers files_read (default on) and files_write, so an owner can grant file access at invite time. Agents that are project members can now read the project's Files and add files via the API. Grant creation already grants these scopes generically, and membership is added via the always-on project_tasks scope. Adds tests/test_routes_project_files_agent_scope.py (10 cases covering read/write allow, missing-scope 403, cross-project 404, unknown-slug 404, session owner unchanged). * feat(agents): surface Files in the invite bundle + fix agent API-surface docs - build_connection_bundle now advertises the project Files endpoints and adds a Files capability section to the join guide when files_read/files_write are granted, plus task_create when project_tasks_create is granted, so a joining agent is told the Files API exists and how to reach it (slug-keyed paths). - docs/agent-coordination.md: drop the non-existent project_doc_review scope, add files_read/files_write, project_tasks_create, and decisions_write to the agent API-surface list, and correct the doc-gate note (it fires only on file add/delete, so it does not catch allowlist edits). - README: replace the understated read-only agent-surface sentence with the real scoped surface (tasks, canvas, files, decisions, a2a). Verified against VALID_SCOPES / _ALLOWED_SCOPES and the auth_middleware allowlist. Invite tests pass (36). --- README.md | 2 +- .../apps/ProjectsApp/InviteAgentDialog.tsx | 2 + docs/agent-coordination.md | 23 ++- .../test_routes_project_files_agent_scope.py | 168 ++++++++++++++++++ tinyagentos/auth_middleware.py | 26 +++ tinyagentos/routes/project_files.py | 90 +++++++++- tinyagentos/routes/project_invites.py | 38 ++++ 7 files changed, 341 insertions(+), 8 deletions(-) create mode 100644 tests/test_routes_project_files_agent_scope.py diff --git a/README.md b/README.md index 2cc104962..e87566e68 100644 --- a/README.md +++ b/README.md @@ -329,7 +329,7 @@ More on the flow in `docs/design/external-agent-onboarding.md`. ### Authentication Password-protected dashboard with persistent sessions. Per-agent API keys. Exempt paths for cluster workers and health checks. -**Agents authenticate with their own identity, not the owner password.** Each registered agent has an Ed25519 registry identity (canonical id + signed JWT). The owner password is human-only and is never handed to an agent. An agent calls scoped endpoints by presenting `Authorization: Bearer `; the route verifies the signature against the registry public key and checks the agent is active and holds the required scope grant. Today this covers the registry feed endpoints (scope `registry_feeds_read`) and the read-only A2A bus proxy `/api/a2a/bus/channels` + `/api/a2a/bus/messages` (scope `a2a_receive`). The Bearer allowlist is exact: a registry JWT authenticates only those agent paths, never an arbitrary route. +**Agents authenticate with their own identity, not the owner password.** Each registered agent has an Ed25519 registry identity (canonical id + signed JWT). The owner password is human-only and is never handed to an agent. An agent calls scoped endpoints by presenting `Authorization: Bearer `; the route verifies the signature against the registry public key and checks the agent is active and holds the required scope grant. Today this covers the registry feed endpoints (scope `registry_feeds_read`), the A2A bus proxy (`a2a_receive` to read, `a2a_send` to post), the kanban board (`project_tasks` for read/lifecycle/comments, `project_tasks_create` to author cards), the canvas (`canvas_read`/`canvas_write`), project Files (`files_read`/`files_write`), and raising a decision (`decisions_write`). The Bearer allowlist is exact: a registry JWT authenticates only those agent paths, never an arbitrary route. Onboarding an internal driver agent: an admin mints its identity once with `taosctl agents mint --handle @taOS-dev --slug taos-dev --scopes a2a_send,a2a_receive` (or `taosctl agents seed-internal` to mint the four built-in driver agents idempotently). Minting prints the registry JWT; store it on the agent host in a gitignored per-host file (for example `~/.config/taos/agent-token`) and have the agent send it as `Authorization: Bearer `. Re-running mint/seed for an existing handle reuses the same canonical id and re-asserts the grants, so it is safe to run again. diff --git a/desktop/src/apps/ProjectsApp/InviteAgentDialog.tsx b/desktop/src/apps/ProjectsApp/InviteAgentDialog.tsx index 3a8a309f0..014c2d2c5 100644 --- a/desktop/src/apps/ProjectsApp/InviteAgentDialog.tsx +++ b/desktop/src/apps/ProjectsApp/InviteAgentDialog.tsx @@ -6,6 +6,8 @@ const SCOPE_PRESETS: { value: string; label: string; defaultOn: boolean; disable { value: "project_tasks_create", label: "project_tasks_create", defaultOn: false, hint: "author NEW cards. project_tasks alone is read, lifecycle and comments only" }, { value: "canvas_read", label: "canvas_read", defaultOn: true }, { value: "canvas_write", label: "canvas_write", defaultOn: true }, + { value: "files_read", label: "files_read", defaultOn: true, hint: "read the project's Files" }, + { value: "files_write", label: "files_write", defaultOn: false, hint: "add or edit files in the project's Files" }, ]; const INTERVAL_PRESETS: { label: string; secs: number }[] = [ diff --git a/docs/agent-coordination.md b/docs/agent-coordination.md index 6c2582476..73413f338 100644 --- a/docs/agent-coordination.md +++ b/docs/agent-coordination.md @@ -105,12 +105,21 @@ The surface, by scope: - **project_tasks** (the kanban board): `GET /api/projects/{pid}/tasks`, `.../tasks/ready`, `.../tasks/{id}`, `.../tasks/{id}/comments` (GET + POST), `POST .../tasks/{id}/(claim|release|close|reopen)`, and - `GET /api/projects/tasks/{id}/context`. Granting project_tasks also makes the - agent a project member. + `GET /api/projects/tasks/{id}/context`. This is read + lifecycle + comments + only. Granting project_tasks also makes the agent a project member. +- **project_tasks_create**: `POST /api/projects/{pid}/tasks` (author new cards). + This is a SEPARATE scope from project_tasks and is off by default; grant it + explicitly when an agent needs to create cards. - **canvas_read**: `GET .../canvas/elements`, `.../canvas/snapshot.png|.tldr`, `.../canvas/stream`. **canvas_write**: `POST .../canvas/elements`, `PATCH|DELETE .../canvas/elements/{id}`. -- **project_doc_review**: `GET .../doc-reviews`, `GET|PUT .../doc-review/{path}`. +- **files_read**: `GET /api/projects/{slug}/files` (list), `.../files/watch`, + `GET .../files/{path}` (download), `.../trash`, `.../stats`. **files_write**: + `POST .../files/upload` (multipart), `POST .../mkdir`, `DELETE .../files/{path}`, + and the trash restore/purge/empty routes. NOTE: the files routes key on the + project SLUG in the path, not the id. +- **decisions_write**: `POST /api/decisions` (raise a human-in-the-loop + decision). Listing/answering decisions stays session-only. - **a2a_send / a2a_receive**: the authenticated bus proxy above (`/api/a2a/bus/send|messages|channels|stream`), which forces `from` to the agent's own handle. @@ -119,9 +128,11 @@ Access is per-project: a token is authorized for a project only when the agent holds an active grant + membership there; a request for a project it has no grant on returns an existence-hiding 404. External agents onboard via the consent flow (`POST /api/agents/auth-requests`) or a project invite (link + -PIN; see issue #1780). When you change the -allowlist in `tinyagentos/auth_middleware.py`, update this section in the same -PR (the doc-gate enforces it). +PIN; see issue #1780). When you change the agent allowlist in +`tinyagentos/auth_middleware.py`, update this section in the same PR. Note the +doc-gate only fires on files ADDED or DELETED, not edits to an existing file, +so it will NOT catch allowlist drift here on its own; keep this list in sync by +hand. Multi-project identities (taOS #1862): one agent identity (the registry JWT) may belong to several projects at once. The grants table keys a grant on diff --git a/tests/test_routes_project_files_agent_scope.py b/tests/test_routes_project_files_agent_scope.py new file mode 100644 index 000000000..bcec14c31 --- /dev/null +++ b/tests/test_routes_project_files_agent_scope.py @@ -0,0 +1,168 @@ +"""Agent scope gating for project-files routes (tinyagentos/routes/project_files.py). + +Mirrors the canvas agent-scope tests: an approved agent reaches a project's +files only with a ``files_read`` / ``files_write`` grant bound to THAT project. +A token bound to a different project, or missing the scope, collapses into an +existence-hiding 404. Session owner behavior is unchanged. +""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest +import pytest_asyncio +from httpx import ASGITransport, AsyncClient + +from tinyagentos.agent_registry_store import mint_registry_token + + +@pytest_asyncio.fixture +async def ctx(client): + app = client._transport.app + for attr in ("agent_registry", "agent_grants"): + store = getattr(app.state, attr) + if store._db is None: + await store.init() + uid = app.state.auth.find_user("admin")["id"] + yield SimpleNamespace(client=client, app=app, uid=uid) + for attr in ("agent_registry", "agent_grants"): + store = getattr(app.state, attr) + if store._db is not None: + await store.close() + + +def _bare(app): + return AsyncClient(transport=ASGITransport(app=app), base_url="http://test") + + +def _hdr(token): + return {"Authorization": f"Bearer {token}"} + + +async def _new_project(ctx, slug): + resp = await ctx.client.post("/api/projects", json={"name": slug, "slug": slug}) + assert resp.status_code == 200, resp.text + return resp.json()["id"] + + +async def _mint_agent(ctx, project_id, scopes, *, handle="@filer"): + registry = ctx.app.state.agent_registry + grants = ctx.app.state.agent_grants + priv, _pub = ctx.app.state.agent_registry_keypair + rec = await registry.register( + framework="grok", + display_name="Filer", + origin="external-selfjoin", + handle=handle, + ) + cid = rec["canonical_id"] + await registry.set_status(cid, "active") + for scope in scopes: + await grants.add_grant(cid, scope, project_id=project_id) + token = mint_registry_token( + cid, priv, user_id="u", framework="grok", project_id=project_id + ) + return cid, token + + +@pytest.mark.asyncio +class TestAgentFilesRead: + async def test_list_allowed_with_files_read(self, ctx): + pid = await _new_project(ctx, "freadok") + _cid, token = await _mint_agent(ctx, pid, ("files_read",)) + async with _bare(ctx.app) as bare: + resp = await bare.get("/api/projects/freadok/files", headers=_hdr(token)) + assert resp.status_code == 200, resp.text + + async def test_list_without_read_scope_is_403(self, ctx): + pid = await _new_project(ctx, "fnogrant") + # files_write only: the files_read scope is entirely absent, so the + # scope check fails before project binding -> 403 (mirrors canvas). + _cid, token = await _mint_agent(ctx, pid, ("files_write",)) + async with _bare(ctx.app) as bare: + resp = await bare.get("/api/projects/fnogrant/files", headers=_hdr(token)) + assert resp.status_code == 403 + assert resp.status_code != 200 + + async def test_stats_allowed_with_files_read(self, ctx): + pid = await _new_project(ctx, "fstats") + _cid, token = await _mint_agent(ctx, pid, ("files_read",)) + async with _bare(ctx.app) as bare: + resp = await bare.get("/api/projects/fstats/stats", headers=_hdr(token)) + assert resp.status_code == 200, resp.text + + +@pytest.mark.asyncio +class TestAgentFilesWrite: + async def test_upload_allowed_with_files_write(self, ctx): + pid = await _new_project(ctx, "fwriteok") + _cid, token = await _mint_agent(ctx, pid, ("files_write",)) + async with _bare(ctx.app) as bare: + resp = await bare.post( + "/api/projects/fwriteok/files/upload", + files={"file": ("note.md", b"# hello", "text/markdown")}, + headers=_hdr(token), + ) + assert resp.status_code == 200, resp.text + assert resp.json()["status"] == "uploaded" + + async def test_upload_without_write_scope_is_403(self, ctx): + pid = await _new_project(ctx, "freadonly") + # files_read only: the files_write scope is entirely absent -> 403. + _cid, token = await _mint_agent(ctx, pid, ("files_read",)) + async with _bare(ctx.app) as bare: + resp = await bare.post( + "/api/projects/freadonly/files/upload", + files={"file": ("note.md", b"x", "text/markdown")}, + headers=_hdr(token), + ) + assert resp.status_code == 403 + assert resp.status_code != 200 + + async def test_mkdir_allowed_with_files_write(self, ctx): + pid = await _new_project(ctx, "fmkdir") + _cid, token = await _mint_agent(ctx, pid, ("files_write",)) + async with _bare(ctx.app) as bare: + resp = await bare.post( + "/api/projects/fmkdir/mkdir", + json={"path": "Reports"}, + headers=_hdr(token), + ) + assert resp.status_code == 200, resp.text + + +@pytest.mark.asyncio +class TestAgentFilesProjectBinding: + async def test_token_for_other_project_is_404(self, ctx): + pid_a = await _new_project(ctx, "proja") + await _new_project(ctx, "projb") + # Grant bound to project A only; attempt to read project B. + _cid, token = await _mint_agent(ctx, pid_a, ("files_read",)) + async with _bare(ctx.app) as bare: + resp = await bare.get("/api/projects/projb/files", headers=_hdr(token)) + assert resp.status_code == 404 + assert resp.status_code != 200 + + async def test_unknown_slug_is_404(self, ctx): + pid = await _new_project(ctx, "realproj") + _cid, token = await _mint_agent(ctx, pid, ("files_read",)) + async with _bare(ctx.app) as bare: + resp = await bare.get("/api/projects/nosuchproj/files", headers=_hdr(token)) + assert resp.status_code == 404 + + +@pytest.mark.asyncio +class TestSessionFilesUnchanged: + async def test_owner_session_list_allowed(self, ctx): + await _new_project(ctx, "ownerfiles") + resp = await ctx.client.get("/api/projects/ownerfiles/files") + assert resp.status_code == 200, resp.text + + async def test_owner_session_upload_allowed(self, ctx): + await _new_project(ctx, "ownerupload") + resp = await ctx.client.post( + "/api/projects/ownerupload/files/upload", + files={"file": ("a.txt", b"data", "text/plain")}, + ) + assert resp.status_code == 200, resp.text diff --git a/tinyagentos/auth_middleware.py b/tinyagentos/auth_middleware.py index 45870c5fe..99b836de7 100644 --- a/tinyagentos/auth_middleware.py +++ b/tinyagentos/auth_middleware.py @@ -88,6 +88,24 @@ ("POST", re.compile(r"^/api/decisions$")), ) +# Project-files routes a files_read / files_write token may reach. Reads +# (list/watch/get/trash-list/stats) require a files_read grant; writes +# (upload/mkdir/delete/restore/purge/empty) require files_write. The route +# verifies the JWT + grant + project binding (slug resolves to the project). +_AGENT_FILES_ROUTES = ( + ("GET", re.compile(rf"^/api/projects/{_SEG}/files$")), + ("GET", re.compile(rf"^/api/projects/{_SEG}/files/watch$")), + ("POST", re.compile(rf"^/api/projects/{_SEG}/files/upload$")), + ("POST", re.compile(rf"^/api/projects/{_SEG}/mkdir$")), + ("GET", re.compile(rf"^/api/projects/{_SEG}/files/.+$")), + ("DELETE", re.compile(rf"^/api/projects/{_SEG}/files/.+$")), + ("GET", re.compile(rf"^/api/projects/{_SEG}/trash$")), + ("POST", re.compile(rf"^/api/projects/{_SEG}/trash/{_SEG}/restore$")), + ("DELETE", re.compile(rf"^/api/projects/{_SEG}/trash/{_SEG}$")), + ("DELETE", re.compile(rf"^/api/projects/{_SEG}/trash$")), + ("GET", re.compile(rf"^/api/projects/{_SEG}/stats$")), +) + def _is_agent_task_path(method: str, path: str) -> bool: """True only for the exact subset of task routes a project_tasks token may @@ -107,6 +125,13 @@ def _is_agent_decisions_path(method: str, path: str) -> bool: """True only for POST /api/decisions, which a decisions_write-bound agent token may reach. The route verifies the JWT + grant.""" return any(m == method and rx.match(path) for m, rx in _AGENT_DECISIONS_ROUTES) + + +def _is_agent_files_path(method: str, path: str) -> bool: + """True only for the project-files routes a files_read / files_write token + may reach. Strict method + anchored-regex match; the route verifies the + JWT + grant + project binding.""" + return any(m == method and rx.match(path) for m, rx in _AGENT_FILES_ROUTES) # Bundle assets and the SPA shell HTML must be reachable without auth so: # 1. The browser can install and cache the shell for offline / PWA use. # 2. After a backend restart the cached shell loads immediately without @@ -349,6 +374,7 @@ async def dispatch(self, request: Request, call_next): or _is_agent_task_path(request.method, path) or _is_agent_canvas_path(request.method, path) or _is_agent_decisions_path(request.method, path) + or _is_agent_files_path(request.method, path) ) and auth_header.lower().startswith("bearer "): request.state.user_id = None request.state.is_admin = False diff --git a/tinyagentos/routes/project_files.py b/tinyagentos/routes/project_files.py index bd5226279..c7e3f129e 100644 --- a/tinyagentos/routes/project_files.py +++ b/tinyagentos/routes/project_files.py @@ -3,8 +3,9 @@ import asyncio import json from pathlib import Path +from typing import Literal -from fastapi import APIRouter, Request, UploadFile, File +from fastapi import APIRouter, HTTPException, Request, UploadFile, File from fastapi.responses import FileResponse, JSONResponse, StreamingResponse from pydantic import BaseModel @@ -32,6 +33,60 @@ def _get_project_files_root(request: Request, slug: str) -> Path | None: return root +_FILES_READ_SCOPE = "files_read" +_FILES_WRITE_SCOPE = "files_write" + + +async def _authorize_files_actor( + request: Request, slug: str, mode: Literal["read", "write"] +) -> "tuple[str, str] | JSONResponse": + """Resolve + authorize the actor for a project-files route. + + Mirrors ``_authorize_canvas_actor``: accepts EITHER a session owner/admin + (human behavior unchanged) OR an approved agent's registry JWT holding the + matching files scope bound to THIS project: + + * read mode -> ``files_read`` grant on the project + * write mode -> ``files_write`` grant on the project + + Returns ``(actor_kind, actor_id)`` on success, or a JSONResponse to return + directly. A token bound to a DIFFERENT project (or an unknown slug) + collapses into an existence-hiding 404 (never confirms the project exists). + """ + ps = request.app.state.project_store + project = await ps.get_project_by_slug(slug) + uid = getattr(request.state, "user_id", None) + if uid: + # Session path: project visibility gate. A non-owner non-admin human + # collapses into the SAME existence-hiding 404 the agent path uses. + is_admin = bool(getattr(request.state, "is_admin", False)) + if project is not None and not is_admin and project.get("user_id") != uid: + return JSONResponse({"error": "not found"}, status_code=404) + return ("user", uid) + auth_header = request.headers.get("Authorization", "") + if not auth_header.lower().startswith("bearer "): + # Middleware normally 401s unauthenticated requests before the route + # runs; a middleware-bypassing test context reaches here, so fall back + # to a system actor (there is no real principal to attribute to). + return ("user", "system") + if project is None: + return JSONResponse({"error": "not found"}, status_code=404) + from tinyagentos.agent_token_auth import ( + check_agent_scope_for_project, + PROJECT_SCOPE_MISMATCH_DETAIL, + ) + scope = _FILES_READ_SCOPE if mode == "read" else _FILES_WRITE_SCOPE + try: + cid = await check_agent_scope_for_project(request, scope, project["id"]) + except HTTPException as exc: + if exc.status_code == 403 and exc.detail == PROJECT_SCOPE_MISMATCH_DETAIL: + return JSONResponse({"error": "not found"}, status_code=404) + raise + if cid is None: + return JSONResponse({"error": "not found"}, status_code=404) + return ("agent", cid) + + def _get_project_trash_dir(request: Request, slug: str) -> Path: """Return the trash directory backing one project's files, creating it on first access.""" @@ -87,6 +142,9 @@ def _dir_signature(entries: list[dict]) -> str: @router.get("/api/projects/{slug}/files") async def api_project_list_files(request: Request, slug: str, path: str = ""): """List files in the project's files folder.""" + auth = await _authorize_files_actor(request, slug, "read") + if isinstance(auth, JSONResponse): + return auth workspace = _get_project_files_root(request, slug) if workspace is None: return JSONResponse({"error": "Invalid slug"}, status_code=400) @@ -100,6 +158,9 @@ async def api_project_list_files(request: Request, slug: str, path: str = ""): @router.get("/api/projects/{slug}/files/watch") async def api_project_watch_files(request: Request, slug: str, path: str = "", interval: float = 1.0): """SSE watch stream for the project's files folder.""" + auth = await _authorize_files_actor(request, slug, "read") + if isinstance(auth, JSONResponse): + return auth workspace = _get_project_files_root(request, slug) if workspace is None: return JSONResponse({"error": "Invalid slug"}, status_code=400) @@ -138,6 +199,9 @@ async def event_stream(): @router.post("/api/projects/{slug}/files/upload") async def api_project_upload_file(request: Request, slug: str, path: str = "", file: UploadFile = File(...)): """Upload a file to the project's files folder.""" + auth = await _authorize_files_actor(request, slug, "write") + if isinstance(auth, JSONResponse): + return auth workspace = _get_project_files_root(request, slug) if workspace is None: return JSONResponse({"error": "Invalid slug"}, status_code=400) @@ -163,6 +227,9 @@ async def api_project_upload_file(request: Request, slug: str, path: str = "", f @router.post("/api/projects/{slug}/mkdir") async def api_project_mkdir(request: Request, slug: str, body: MkdirRequest): """Create a directory in the project's files folder.""" + auth = await _authorize_files_actor(request, slug, "write") + if isinstance(auth, JSONResponse): + return auth workspace = _get_project_files_root(request, slug) if workspace is None: return JSONResponse({"error": "Invalid slug"}, status_code=400) @@ -184,6 +251,9 @@ async def api_project_mkdir(request: Request, slug: str, body: MkdirRequest): @router.get("/api/projects/{slug}/files/{file_path:path}") async def api_project_get_file(request: Request, slug: str, file_path: str): """Stream a single file from the project's files folder.""" + auth = await _authorize_files_actor(request, slug, "read") + if isinstance(auth, JSONResponse): + return auth workspace = _get_project_files_root(request, slug) if workspace is None: return JSONResponse({"error": "Invalid slug"}, status_code=400) @@ -202,6 +272,9 @@ async def api_project_delete_file(request: Request, slug: str, file_path: str): See ``tinyagentos/routes/user_workspace.py::api_delete_file`` for why — this mirrors the same move-to-trash behavior for project files. """ + auth = await _authorize_files_actor(request, slug, "write") + if isinstance(auth, JSONResponse): + return auth workspace = _get_project_files_root(request, slug) if workspace is None: return JSONResponse({"error": "Invalid slug"}, status_code=400) @@ -222,6 +295,9 @@ async def api_project_delete_file(request: Request, slug: str, file_path: str): @router.get("/api/projects/{slug}/trash") async def api_project_list_trash(request: Request, slug: str): """List items in a project's trash, newest-deleted first.""" + auth = await _authorize_files_actor(request, slug, "read") + if isinstance(auth, JSONResponse): + return auth if _get_project_files_root(request, slug) is None: return JSONResponse({"error": "Invalid slug"}, status_code=400) trash_dir = _get_project_trash_dir(request, slug) @@ -231,6 +307,9 @@ async def api_project_list_trash(request: Request, slug: str): @router.post("/api/projects/{slug}/trash/{item_id}/restore") async def api_project_restore_trash_item(request: Request, slug: str, item_id: str): """Restore a trashed item back to its original path in the project's files folder.""" + auth = await _authorize_files_actor(request, slug, "write") + if isinstance(auth, JSONResponse): + return auth workspace = _get_project_files_root(request, slug) if workspace is None: return JSONResponse({"error": "Invalid slug"}, status_code=400) @@ -249,6 +328,9 @@ async def api_project_restore_trash_item(request: Request, slug: str, item_id: s @router.delete("/api/projects/{slug}/trash/{item_id}") async def api_project_purge_trash_item(request: Request, slug: str, item_id: str): """Permanently delete one item from a project's trash.""" + auth = await _authorize_files_actor(request, slug, "write") + if isinstance(auth, JSONResponse): + return auth if _get_project_files_root(request, slug) is None: return JSONResponse({"error": "Invalid slug"}, status_code=400) trash_dir = _get_project_trash_dir(request, slug) @@ -264,6 +346,9 @@ async def api_project_purge_trash_item(request: Request, slug: str, item_id: str @router.delete("/api/projects/{slug}/trash") async def api_project_empty_trash(request: Request, slug: str): """Permanently delete every item in a project's trash.""" + auth = await _authorize_files_actor(request, slug, "write") + if isinstance(auth, JSONResponse): + return auth if _get_project_files_root(request, slug) is None: return JSONResponse({"error": "Invalid slug"}, status_code=400) trash_dir = _get_project_trash_dir(request, slug) @@ -274,6 +359,9 @@ async def api_project_empty_trash(request: Request, slug: str): @router.get("/api/projects/{slug}/stats") async def api_project_stats(request: Request, slug: str): """Return total file count and total size for the project's files folder.""" + auth = await _authorize_files_actor(request, slug, "read") + if isinstance(auth, JSONResponse): + return auth workspace = _get_project_files_root(request, slug) if workspace is None: return JSONResponse({"error": "Invalid slug"}, status_code=400) diff --git a/tinyagentos/routes/project_invites.py b/tinyagentos/routes/project_invites.py index 0717c94a6..68adf8268 100644 --- a/tinyagentos/routes/project_invites.py +++ b/tinyagentos/routes/project_invites.py @@ -366,19 +366,32 @@ async def build_connection_bundle( apis: dict = {} scopeset = set(granted_scopes) has_tasks = "project_tasks" in scopeset + has_tasks_create = "project_tasks_create" in scopeset has_canvas_read = "canvas_read" in scopeset has_canvas_write = "canvas_write" in scopeset + has_files_read = "files_read" in scopeset + has_files_write = "files_write" in scopeset if has_tasks: apis["tasks_list"] = f"/api/projects/{pid}/tasks" apis["tasks_ready"] = f"/api/projects/{pid}/tasks/ready" apis["task_lifecycle"] = f"/api/projects/{pid}/tasks/{{task_id}}/(claim|release|close|reopen)" apis["task_comments"] = f"/api/projects/{pid}/tasks/{{task_id}}/comments" + if has_tasks_create: + apis["task_create"] = f"/api/projects/{pid}/tasks" if has_canvas_read: apis["canvas_elements"] = f"/api/projects/{pid}/canvas/elements" apis["canvas_snapshot"] = f"/api/projects/{pid}/canvas/snapshot.png" if has_canvas_write: apis["canvas_element"] = f"/api/projects/{pid}/canvas/elements/{{eid}}" + # Files routes key on the project SLUG (not the id) in the path. + if has_files_read: + apis["files_list"] = f"/api/projects/{project_slug}/files" + apis["file_get"] = f"/api/projects/{project_slug}/files/{{path}}" + apis["files_stats"] = f"/api/projects/{project_slug}/stats" + if has_files_write: + apis["file_upload"] = f"/api/projects/{project_slug}/files/upload" + apis["files_mkdir"] = f"/api/projects/{project_slug}/mkdir" # A2A bus routes are always advertised (a2a_send / a2a_receive are part of # the invite's default scope set, but include them whenever either scope is @@ -420,6 +433,8 @@ async def build_connection_bundle( has_tasks=has_tasks, has_canvas_read=has_canvas_read, has_canvas_write=has_canvas_write, + has_files_read=has_files_read, + has_files_write=has_files_write, check_interval_secs=check_interval_secs, ) @@ -453,6 +468,8 @@ def _build_guide_markdown( has_tasks: bool, has_canvas_read: bool, has_canvas_write: bool, + has_files_read: bool = False, + has_files_write: bool = False, check_interval_secs: int, ) -> str: """Generate the personalized capability guide from granted scopes + project @@ -504,6 +521,27 @@ def _build_guide_markdown( if not has_canvas_read and not has_canvas_write: lines.append("- You have no canvas access in this project.") lines.append("") + lines.append("## Your capabilities in the project Files") + lines.append("") + if has_files_read: + lines.append( + f"- Files read: list and download this project's Files. " + f"List with `GET /api/projects/{project_slug}/files?path=` and read one " + f"with `GET /api/projects/{project_slug}/files/`. Use the project slug " + f"`{project_slug}` in the path (not the internal id)." + ) + else: + lines.append("- Files read: NOT granted.") + if has_files_write: + lines.append( + f"- Files write: add and edit Files. Upload with " + f"`POST /api/projects/{project_slug}/files/upload?path=` as multipart " + f"`file=@...`, and make folders with `POST /api/projects/{project_slug}/mkdir` " + f"`{{\"path\": \"\"}}`." + ) + else: + lines.append("- Files write: NOT granted.") + lines.append("") lines.append("## A2A bus contract (authenticated proxy)") lines.append("") if "a2a_send" in scopeset or "a2a_receive" in scopeset: From 794a151288e4a1b449ef3b150150be45cdd941b1 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Wed, 22 Jul 2026 13:17:41 +0100 Subject: [PATCH 005/116] feat(providers): add Nous Portal as a cloud model provider (#2102) Nous Portal (Nous Research) is an OpenAI-compatible inference API serving the Hermes 4 family and frontier models. Wire it up as a first-class cloud provider so it can be added from the Providers app instead of a hand-configured openai-compatible endpoint. - providers/__init__.py: add 'nous' to ALL_TYPES + CLOUD_TYPES, and map it to the OpenAI LiteLLM prefix (api_base set explicitly, like kilocode). - routes/providers.py: default base URL https://inference-api.nousresearch.com/v1 and a seed model list (flagship Hermes models) for the case where /v1/models cannot be listed without a working credential. - backend_adapters.py: 'nous' uses the CloudAPIAdapter probe. - Frontend: add 'nous' to the cloud provider type lists and the Providers app metadata (label 'Nous Portal', default URL, description, key placeholder). Base URL and OpenAI-compatibility verified against Nous Portal docs. Backend provider suite passes (68); frontend tsc clean. --- desktop/src/apps/ProvidersApp.tsx | 9 ++++++++- desktop/src/lib/models.ts | 2 +- tinyagentos/backend_adapters.py | 1 + tinyagentos/providers/__init__.py | 3 +++ tinyagentos/routes/providers.py | 9 +++++++++ 5 files changed, 22 insertions(+), 2 deletions(-) diff --git a/desktop/src/apps/ProvidersApp.tsx b/desktop/src/apps/ProvidersApp.tsx index 720003d07..8e519319a 100644 --- a/desktop/src/apps/ProvidersApp.tsx +++ b/desktop/src/apps/ProvidersApp.tsx @@ -18,7 +18,7 @@ import { useIsMobile } from "@/hooks/use-is-mobile"; /* ------------------------------------------------------------------ */ /** Fallback constants used before the API call completes. */ -const FALLBACK_CLOUD_TYPES = ["openai", "anthropic", "openrouter", "kilocode", "deepseek", "openai-compatible"] as const; +const FALLBACK_CLOUD_TYPES = ["openai", "anthropic", "openrouter", "kilocode", "deepseek", "nous", "openai-compatible"] as const; const FALLBACK_LOCAL_TYPES = ["rkllama", "ollama", "llama-cpp", "vllm", "exo", "mlx", "sd-cpp"] as const; /** Active cloud types — seeded with fallback, updated from /api/providers/types. @@ -35,6 +35,7 @@ const DEFAULT_URLS: Partial> = { openrouter: "https://openrouter.ai/api/v1", kilocode: "https://api.kilo.ai/api/gateway", deepseek: "https://api.deepseek.com", + nous: "https://inference-api.nousresearch.com/v1", ollama: "http://localhost:11434", // taOS default rkllama port since the 7833 migration (adjacent to qmd on // 7832) -- see _DEFAULT_RKLLAMA_PORT in rkllama_installer.py. 8080 is the @@ -83,6 +84,12 @@ const CLOUD_PROVIDER_META: Record = { url: "https://api.deepseek.com", keyPlaceholder: "sk-...", }, + nous: { + label: "Nous Portal", + description: "Hermes 4 and frontier models via Nous Research", + url: "https://inference-api.nousresearch.com/v1", + keyPlaceholder: "your Portal token", + }, "openai-compatible": { label: "OpenAI-Compatible", description: "LiteLLM, llama.cpp server, vLLM, or any service exposing the OpenAI API", diff --git a/desktop/src/lib/models.ts b/desktop/src/lib/models.ts index e432cbc34..b2e2fa2de 100644 --- a/desktop/src/lib/models.ts +++ b/desktop/src/lib/models.ts @@ -127,7 +127,7 @@ export interface CloudProvider { source?: string; } -export const CLOUD_PROVIDER_TYPES = ["openai", "anthropic", "openrouter", "kilocode", "deepseek", "openai-compatible"] as const; +export const CLOUD_PROVIDER_TYPES = ["openai", "anthropic", "openrouter", "kilocode", "deepseek", "nous", "openai-compatible"] as const; /** Flatten /api/providers cloud providers into AggregatedModel entries. */ export function cloudProvidersToAggregated(providers: CloudProvider[]): AggregatedModel[] { diff --git a/tinyagentos/backend_adapters.py b/tinyagentos/backend_adapters.py index 6579bf795..2417cef91 100644 --- a/tinyagentos/backend_adapters.py +++ b/tinyagentos/backend_adapters.py @@ -242,6 +242,7 @@ async def health(self, client: httpx.AsyncClient, url: str) -> dict: "openrouter": CloudAPIAdapter(), "kilocode": CloudAPIAdapter(), "deepseek": CloudAPIAdapter(), + "nous": CloudAPIAdapter(), "openai-compatible": CloudAPIAdapter(), "sd-cpp": StableDiffusionCppAdapter(), "iopaint": IOPaintAdapter(), diff --git a/tinyagentos/providers/__init__.py b/tinyagentos/providers/__init__.py index 68c5d4f19..31dfe85d6 100644 --- a/tinyagentos/providers/__init__.py +++ b/tinyagentos/providers/__init__.py @@ -32,6 +32,7 @@ "openrouter", "kilocode", "deepseek", + "nous", "openai-compatible", # -- local image-generation backends -- "sd-cpp", @@ -46,6 +47,7 @@ "openrouter", "kilocode", "deepseek", + "nous", "openai-compatible", } @@ -74,6 +76,7 @@ "openrouter": "openrouter", "kilocode": "openai", # kilocode is OpenAI-compatible; api_base set explicitly "deepseek": "deepseek", # native LiteLLM provider; api_base set to official base + "nous": "openai", # Nous Portal is OpenAI-compatible; api_base set explicitly "openai-compatible": "openai", # user-supplied OpenAI-compatible endpoint } diff --git a/tinyagentos/routes/providers.py b/tinyagentos/routes/providers.py index 90b805d3e..3d2a17980 100644 --- a/tinyagentos/routes/providers.py +++ b/tinyagentos/routes/providers.py @@ -27,6 +27,7 @@ "anthropic": "https://api.anthropic.com/v1", "openrouter": "https://openrouter.ai/api/v1", "deepseek": "https://api.deepseek.com", + "nous": "https://inference-api.nousresearch.com/v1", } # Seed model list for cloud providers that don't expose an openly-listable @@ -46,6 +47,14 @@ {"id": "deepseek-chat"}, {"id": "deepseek-reasoner"}, ], + # Nous Portal authenticates with an OAuth-minted bearer, so a fresh add + # without a working credential can't auto-discover /v1/models. Seed the + # flagship Hermes models so the entry registers routable models either way. + "nous": [ + {"id": "Hermes-4-405B"}, + {"id": "Hermes-4-70B"}, + {"id": "Hermes-4.3-36B"}, + ], } From b4f2b1c2130d8fad0fa46092e335447fce7314aa Mon Sep 17 00:00:00 2001 From: jaylfc Date: Wed, 22 Jul 2026 15:15:55 +0100 Subject: [PATCH 006/116] feat(desktop): Assistant Studio - a workspace for a personal-assistant agent (#2103) A new studio app where the user picks a registered agent to be their PA and works out of one hub. Left rail: Overview, Journal, Calendar/time, Tasks, Comms, Canvas, and a Deliverables (files/reports) area. The PA picker defaults to Hermes when present and persists the choice. Journal, Tasks, Calendar events and Deliverables persist locally per PA so switching PA swaps the whole workspace; Comms opens the live agent chat and Canvas points at the project canvas. MVP scope: self-contained, no new backend (localStorage-backed), so it is additive and safe. Accessible (labels, aria-current, keyboard add). Registered as an optional studio app. Backend wiring (real calendar, PA-scoped board/files) is a follow-up. tsc clean; frontend build passes. --- desktop/src/apps/AssistantStudioApp.tsx | 704 ++++++++++++++++++++++++ desktop/src/registry/app-registry.ts | 1 + 2 files changed, 705 insertions(+) create mode 100644 desktop/src/apps/AssistantStudioApp.tsx diff --git a/desktop/src/apps/AssistantStudioApp.tsx b/desktop/src/apps/AssistantStudioApp.tsx new file mode 100644 index 000000000..331b8ae1f --- /dev/null +++ b/desktop/src/apps/AssistantStudioApp.tsx @@ -0,0 +1,704 @@ +import { useState, useEffect, useCallback, useMemo } from "react"; +import { + LayoutDashboard, + NotebookPen, + CalendarDays, + ListTodo, + MessagesSquare, + PenTool, + FolderKanban, + UserRound, + Plus, + Check, + Trash2, + ExternalLink, +} from "lucide-react"; + +/* ------------------------------------------------------------------ */ +/* Assistant Studio - the workspace for your personal assistant (PA) */ +/* */ +/* Pick a registered agent to be your PA, then work out of a single */ +/* hub: Overview, Journal, Calendar / time, Tasks, Comms, Canvas, and */ +/* a Deliverables (files / reports) area. Journal / Tasks / Calendar */ +/* events / Deliverables persist locally per PA so switching PA swaps */ +/* the whole workspace. Comms and Canvas open the live taOS surfaces. */ +/* ------------------------------------------------------------------ */ + +type StudioView = + | "overview" + | "journal" + | "calendar" + | "tasks" + | "comms" + | "canvas" + | "deliverables"; + +const RAIL: { id: StudioView; label: string; icon: typeof LayoutDashboard }[] = [ + { id: "overview", label: "Overview", icon: LayoutDashboard }, + { id: "journal", label: "Journal", icon: NotebookPen }, + { id: "calendar", label: "Calendar", icon: CalendarDays }, + { id: "tasks", label: "Tasks", icon: ListTodo }, + { id: "comms", label: "Comms", icon: MessagesSquare }, + { id: "canvas", label: "Canvas", icon: PenTool }, + { id: "deliverables", label: "Deliverables", icon: FolderKanban }, +]; + +interface Agent { + name: string; + display_name?: string; + handle?: string; + framework?: string; +} + +interface JournalEntry { + id: string; + ts: number; + body: string; +} +interface Task { + id: string; + title: string; + due?: string; + done: boolean; +} +interface CalEvent { + id: string; + date: string; + title: string; +} +interface Deliverable { + id: string; + title: string; + status: "draft" | "in-progress" | "delivered"; + link?: string; +} + +const PA_KEY = "taos.assistantStudio.pa"; +const nsKey = (pa: string, kind: string) => `taos.assistantStudio.${pa}.${kind}`; + +function loadJSON(key: string, fallback: T): T { + try { + const raw = localStorage.getItem(key); + return raw ? (JSON.parse(raw) as T) : fallback; + } catch { + return fallback; + } +} +function saveJSON(key: string, value: unknown) { + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch { + /* quota / private mode: non-fatal */ + } +} +const rid = () => Math.random().toString(36).slice(2, 10); +const fmtDate = (ts: number) => + new Date(ts).toLocaleDateString(undefined, { month: "short", day: "numeric" }); + +export function AssistantStudioApp({ windowId: _windowId }: { windowId: string }) { + const [view, setView] = useState("overview"); + const [agents, setAgents] = useState([]); + const [pa, setPa] = useState(() => localStorage.getItem(PA_KEY) || ""); + const [loadingAgents, setLoadingAgents] = useState(true); + + // Load the registered agents so the user can pick a PA. Best effort: a fetch + // failure just leaves the picker with whatever PA was already chosen. + useEffect(() => { + let alive = true; + (async () => { + try { + const res = await fetch("/api/agents"); + const data = res.ok ? await res.json() : []; + const list: Agent[] = Array.isArray(data) ? data : data.agents || []; + if (!alive) return; + setAgents(list); + // Default the PA to Hermes when nothing is chosen yet. + const first = list[0]; + if (!localStorage.getItem(PA_KEY) && first) { + const hermes = list.find((a) => + (a.name || a.handle || "").toLowerCase().includes("hermes"), + ); + const chosen = (hermes || first).name; + setPa(chosen); + localStorage.setItem(PA_KEY, chosen); + } + } catch { + /* offline / no agents: keep the current PA */ + } finally { + if (alive) setLoadingAgents(false); + } + })(); + return () => { + alive = false; + }; + }, []); + + const choosePa = (name: string) => { + setPa(name); + localStorage.setItem(PA_KEY, name); + }; + + const paAgent = useMemo( + () => agents.find((a) => a.name === pa), + [agents, pa], + ); + const paLabel = paAgent?.display_name || paAgent?.handle || pa || "no PA selected"; + + return ( +
+ {/* Left rail */} + + + {/* Active surface */} +
+ {view === "overview" && ( + + )} + {view === "journal" && } + {view === "calendar" && } + {view === "tasks" && } + {view === "comms" && } + {view === "canvas" && } + {view === "deliverables" && } +
+
+ ); +} + +/* ---------- shared header ---------- */ +function Header({ title, sub }: { title: string; sub?: string }) { + return ( +
+

{title}

+ {sub &&

{sub}

} +
+ ); +} + +/* ---------- Overview ---------- */ +function OverviewView({ + pa, + paLabel, + onNavigate, +}: { + pa: string; + paLabel: string; + onNavigate: (v: StudioView) => void; +}) { + const tasks = pa ? loadJSON(nsKey(pa, "tasks"), []) : []; + const open = tasks.filter((t) => !t.done); + const journal = pa ? loadJSON(nsKey(pa, "journal"), []) : []; + const today = new Date().toISOString().slice(0, 10); + const dueToday = open.filter((t) => t.due === today); + + return ( +
+
+
+ onNavigate("tasks")} /> + onNavigate("calendar")} /> + onNavigate("journal")} /> +
+
+

Today

+ {dueToday.length === 0 ? ( +

Nothing due today.

+ ) : ( +
    + {dueToday.map((t) => ( +
  • + - {t.title} +
  • + ))} +
+ )} +
+
+ ); +} +function Stat({ label, value, onClick }: { label: string; value: number; onClick: () => void }) { + return ( + + ); +} + +/* ---------- Journal ---------- */ +function JournalView({ pa }: { pa: string }) { + const key = nsKey(pa, "journal"); + const [entries, setEntries] = useState(() => loadJSON(key, [])); + const [draft, setDraft] = useState(""); + useEffect(() => setEntries(loadJSON(key, [])), [key]); + + const add = useCallback(() => { + if (!draft.trim() || !pa) return; + const next = [{ id: rid(), ts: Date.now(), body: draft.trim() }, ...entries]; + setEntries(next); + saveJSON(key, next); + setDraft(""); + }, [draft, entries, key, pa]); + + const remove = (id: string) => { + const next = entries.filter((e) => e.id !== id); + setEntries(next); + saveJSON(key, next); + }; + + return ( +
+
+
+
+