Update
This commit is contained in:
@@ -156,7 +156,7 @@ Docs (`routers/docs/pages.py` `DOCS_PAGES`, re-exported from the `routers/docs`
|
||||
- **Static asset URLs are boot-versioned for cache-busting.** Never hardcode a bare `/static/...` href/src: templates wrap it in the `static_url(path)` Jinja global and runtime JS that builds an absolute static URL uses `assetUrl(path)` (`static/js/assetVersion.js`). Both emit a `/static/v<STATIC_VERSION>/...` path segment (`config.STATIC_VERSION` = `DEVPLACE_STATIC_VERSION` env or the boot unix timestamp), so a deploy busts every asset while it stays served `immutable, max-age=31536000`. The version is in the PATH (not a `?v=` query) so relative ES6 imports and relative CSS `url()` inherit it automatically. `/static/uploads/` (user content) and `service-worker.js` are excluded. See `AGENTS.md` -> "Static asset caching and versioning".
|
||||
- All JS in `static/js/` is ES6 modules, one class per file, instantiated as `app` and reachable everywhere. The browser-side application root is `Application.js`.
|
||||
- **Custom web components** live in `static/js/components/` (prefix `dp-`): `dp-avatar`, `dp-code`, `dp-content` (wraps the `contentRenderer` markdown/sanitize/media engine behind `data-render`) and `dp-title` (its inline title-sized companion) - **both are now used ONLY for live/client-generated content** (chat, planning report, dynamically inserted bubbles); server-rendered content/titles use the backend `render_content`/`render_title` globals instead (see Content rendering pipeline). The components are intentionally kept for those live contexts and future use, `dp-toast`, `dp-dialog`, `dp-context-menu`, `dp-upload` (the single file-upload button used everywhere; replaced the old `AttachmentUploader`), `dp-lightbox` (`app.lightbox`: the single image lightbox, attribute-wired via one delegated listener so any `img[data-lightbox]` opens full-size - `data-full` for a distinct full-res URL; attachment thumbnails and all `data-render` markdown images are marked centrally, the latter in `ContentEnhancer`). Each extends `Component` (a thin `HTMLElement` base with `attr`/`boolAttr`/`intAttr` helpers), renders into the **light DOM** (no shadow root, so global CSS applies) and self-registers via `customElements.define` at the bottom of its file. `components/index.js` imports them all and is imported by `Application.js`, so every element is defined site-wide. The singletons are created once in `Application.js` and exposed as `app.dialog` / `app.contextMenu` / `app.toast`. Self-contained presentational widgets become components; partial-bound controllers (votes, reactions, comments) and pure utilities stay plain modules because they enhance server-rendered markup rather than render standalone UI. They are documented in the public docs **Components** section.
|
||||
- **Shared frontend utilities (do not re-implement these):** `Http` (`static/js/Http.js`) is the single fetch helper (`getJson`, `sendForm`, and `send` which throws `error.message` on non-2xx or a `200 {ok:false}` body); every live-update loop uses `Poller` (`new Poller(fn, intervalMs, {pauseHidden})`); async-job status polls use `JobPoller.run(statusUrl, {onDone, onFailed, onTimeout})` (`ProjectForker`, `ZipDownloader`); click-to-POST engagement controllers (`VoteManager`/`ReactionBar`/`BookmarkManager`/`PollManager`) extend `OptimisticAction` and call `this.submit(url, params, errorTarget, render)`. Floating windows (container terminals and Devii) extend `FloatingWindow`. See `AGENTS.md` -> "Shared frontend utilities (DRY)".
|
||||
- **Shared frontend utilities (do not re-implement these):** `Http` (`static/js/Http.js`) is the single fetch helper (`getJson`, `sendForm`, and `send` which throws `error.message` on non-2xx or a `200 {ok:false}` body); every live-update loop uses `Poller` (`new Poller(fn, intervalMs, {pauseHidden})`); async-job status polls use `JobPoller.run(statusUrl, {onDone, onFailed, onTimeout})` (`ProjectForker`, `ZipDownloader`); click-to-POST engagement controllers (`VoteManager`/`ReactionBar`/`BookmarkManager`/`PollManager`) extend `OptimisticAction` and call `this.submit(url, params, errorTarget, render)`. Floating windows (container terminals and Devii) extend `FloatingWindow`. **Scroll restoration is `ScrollMemory`** (`static/js/ScrollMemory.js`, `app.scrollMemory`): per-tab (sessionStorage) positions keyed by exact `path+search`, restored ONLY on `back_forward`/`reload` navigations or a click on `a.back-link`/`[data-scroll-back]`/breadcrumb/previous-trail-URL links, applied via a layout-stable rAF loop that aborts on user input; it sets `history.scrollRestoration = "manual"` site-wide and upgrades query-less back-link hrefs to the exact previous URL - mark any "back to X" anchor with the `back-link` class and never hand-roll scroll persistence. See `AGENTS.md` -> "Shared frontend utilities (DRY)".
|
||||
- CDN scripts in `base.html` (marked, highlight.js, emoji-picker-element) MUST use `defer` or `type="module"` - otherwise `wait_until="domcontentloaded"` in Playwright tests will time out.
|
||||
- For clickable avatars/usernames, reuse `templates/_avatar_link.html` and `templates/_user_link.html` via `{% include %}` with `_user`, `_size`, `_size_class` locals.
|
||||
- The three public listings (`/feed`, `/gists`, `/projects`) share one search box: the `templates/_sidebar_search.html` partial (included at the top of `.sidebar-card` with `_action`/`_placeholder`/`_hidden` locals) plus the `database.text_search_clause(table, search, fields, author_field)` data helper. Each listing matches its text fields PLUS the author username: pass `author_field="user_uid"` and the helper resolves matching usernames to uids via `database.get_uids_by_username_match(search)` and OR-includes `user_uid IN (...)` (no JOIN, no separate `ilike`). Any new listing with a left filter panel reuses both - never re-inline an `ilike` search clause, never add a JOIN for author matching, and never hand-roll another search form. See `AGENTS.md` -> "Listing search".
|
||||
@@ -245,7 +245,7 @@ Users and guests inject their own **CSS** (look) and **JS** (behaviour), scoped
|
||||
Every notification type (`NOTIFICATION_TYPES`) is independently toggleable per user on three channels (in-app, push, telegram). Channels are data-driven via `NOTIFICATION_CHANNELS` / `_NOTIFICATION_CHANNEL_COLUMNS` / `_NOTIFICATION_CHANNEL_DEFAULTS` (in-app/push default on, **telegram defaults off for every type**); the pref helpers iterate them, so adding a channel is a constant entry + a `notification_preferences` column in `init_db`. Resolution `notification_enabled(user_uid, type, channel)` = live `notification_preferences` override -> admin global default (`notif_default_{type}_{channel}` setting) -> the channel fallback. Enforcement is the single funnel `utils.create_notification` (in-app insert gated by the `in_app` channel, `_schedule_push` by `push`, `_schedule_telegram` by `telegram`); the `messages.py` DM path was routed through `create_notification` so it is gated too. **Telegram delivery is cross-worker:** `_schedule_telegram` enqueues a `telegram_outbox` row (no-op if the user is not paired); `services/telegram/outbox_service.py` `TelegramOutboxService` (lock-owner `BaseService`, default-enabled, ~2s) drains it via `TelegramService.send_markdown` on the worker that owns the bot subprocess (rows wait if the bot is down). The profile Telegram column is disabled until the user pairs Telegram. **`create_notification` defers its work to the background task queue** (`background.submit(_deliver_notification, ...)`), so the preference reads + in-app insert + push schedule + audit all run off the request path (inline in tests). See `### Background task queue`. UI: profile **Notifications** tab (`/profile/{username}?tab=notifications`, owner-or-admin, `POST /profile/{username}/notifications` + `/reset`) and admin global defaults at `/admin/notifications`. Devii tools `notification_list`/`notification_set`/`notification_reset` (`handler="notification"`, owner-scoped, `set`/`reset` in `CONFIRM_REQUIRED`). Storage is the soft-deletable `notification_preferences` table, cached under the `"notif_prefs"` cache-version name. Reuse this canonical-list + resolver + override-table + single-funnel pattern for future per-user per-channel preferences. **Live toasts ride the `in_app` channel:** `services/notification_relay.py` `NotificationRelayService` (lock-owner `BaseService`, like `MessageRelay`) polls `notifications.id > watermark` and publishes each new row to the pub/sub topic `user.{uid}.notifications`; because it runs on the lock owner where every `/pubsub/ws` subscriber converges, the in-process `services.pubsub.publish` reaches the recipient, and because the row exists only when `in_app` is enabled the toast fires exactly for enabled notifications (no new channel). Frontend `static/js/LiveNotifications.js` (`app.liveNotifications`) reads `<body data-user-uid>`, subscribes via `app.pubsub`, and raises a click-through `app.toast`. See AGENTS.md -> "Notification preferences". **The same relay tick also publishes fresh unread counts** `{notifications, messages}` to `user.{uid}.counts` (one publish point covers new notifications AND new DMs, since a DM inserts a `message`-type notification row); `static/js/CounterManager.js` (`app.counters`, constructed with `this.pubsub`) subscribes for instant badge bumps and keeps a 60s `/notifications/counts` poll as a decrement/missed-frame fallback.
|
||||
|
||||
### Live view relay (`devplacepy/services/live_view_relay.py`)
|
||||
`LiveViewRelayService` is a second lock-owner pub/sub bridge (default-enabled, 1s interval, registered in `main.py`) that **replaces per-client HTTP polling on the admin live views with server push, computing a snapshot only for topics that currently have subscribers**. Each tick it reads `pubsub.topics()`, matches concrete subscribed topics against the `VIEWS` registry `(regex, async compute, min_interval)`, throttles per topic, and publishes the **same payload shape the matching HTTP endpoint already returns** (so frontend render code is unchanged). Topics + cadence: `container.list` (4s), `project.{slug}.containers` (3s), `container.{uid}.detail` (4s), `container.{uid}.logs` (3s), `fleet.bots` (2s), `admin.services` (5s), `admin.services.{name}` (5s), `admin.ai-usage.{hours}` (15s), `admin.backups` (8s) - all admin-only by pub/sub policy. The frontend monitors (`ContainerInstance`/`ContainerList`/`ContainerManager`/`BotMonitor`/`ServiceMonitor`/`AiUsageMonitor`/`BackupMonitor`) subscribe via `window.app.pubsub` and keep a lengthened (15-30s) HTTP poll as initial-load + fallback. **The `admin.backups` view is published with `can_download=False`** so the primary-admin-only backup `download_url` never traverses the bus (the same withholding every backups endpoint applies); `BackupMonitor` establishes download capability from its authoritative HTTP poll and builds the `/admin/backups/{uid}/download` URL client-side. To add an admin live view: add one `VIEWS` row returning the endpoint payload and subscribe on the frontend. Keep durable/ordered/stateful/raw-I/O channels (messages, Devii, SEO/DeepSearch progress, container PTY) OFF pub/sub. See `pubreport.md` and AGENTS.md -> "Live view relay".
|
||||
`LiveViewRelayService` is a second lock-owner pub/sub bridge (default-enabled, 1s interval, registered in `main.py`) that **replaces per-client HTTP polling on the admin live views with server push, computing a snapshot only for topics that currently have subscribers**. Each tick it reads `pubsub.topics()`, matches concrete subscribed topics against the `VIEWS` registry `(regex, async compute, min_interval)`, throttles per topic, and publishes the **same payload shape the matching HTTP endpoint already returns** (so frontend render code is unchanged). Topics + cadence: `container.list` (4s), `project.{slug}.containers` (3s), `container.{uid}.detail` (4s), `container.{uid}.logs` (3s), `fleet.bots` (2s), `admin.services` (5s), `admin.services.{name}` (5s), `admin.ai-usage.{hours}` (15s), `admin.backups` (8s) - all admin-only by pub/sub policy. The frontend monitors (`ContainerInstance`/`ContainerList`/`ContainerManager`/`BotMonitor`/`ServiceMonitor`/`AiUsageMonitor`/`BackupMonitor`) subscribe via `window.app.pubsub` and keep a lengthened (15-30s) HTTP poll as initial-load + fallback. **Container topics never broadcast private-project instances**: `container.list` publishes only public-project rows with `partial: true` (`ContainerList.merge` updates by uid, never removes, so private rows from the authoritative HTTP poll survive), and `project.{slug}.containers` / `container.{uid}.detail` / `container.{uid}.logs` skip private-project targets entirely (owners fall back to their HTTP polls). **The `admin.backups` view is published with `can_download=False`** so the primary-admin-only backup `download_url` never traverses the bus (the same withholding every backups endpoint applies); `BackupMonitor` establishes download capability from its authoritative HTTP poll and builds the `/admin/backups/{uid}/download` URL client-side. To add an admin live view: add one `VIEWS` row returning the endpoint payload and subscribe on the frontend. Keep durable/ordered/stateful/raw-I/O channels (messages, Devii, SEO/DeepSearch progress, container PTY) OFF pub/sub. See `pubreport.md` and AGENTS.md -> "Live view relay".
|
||||
|
||||
### Online presence (`devplacepy/services/presence.py`)
|
||||
User online status is a single **`users.last_seen`** (UTC ISO) column - never a new table, never per-load inserts. The write path is the `main.py` `track_presence` middleware: for the resolved current user on every non-asset request it calls `presence.touch(uid)`, which is a **per-worker in-memory throttle** (`_last_write` dict) that issues one in-place `UPDATE users SET last_seen=...` at most once per `config.PRESENCE_WRITE_SECONDS` (= half the timeout) per user per worker - so continuous browsing costs a dict lookup, not a write, and the row never grows. The read path is free: `presence.is_online(user_row)` compares `last_seen` to now against `config.PRESENCE_TIMEOUT_SECONDS` (`DEVPLACE_PRESENCE_TIMEOUT_SECONDS`, default 60), and profile/messages render the initial dot off the user row they already loaded (no extra query). It is the **only** cross-worker-correct approach here because pub/sub is in-process (SQLite is the sole shared medium). Registered as the Jinja global `is_online(user)` (`templating.py`), on `UserOut.last_seen` and `ProfileOut.profile_online`. **Live updates** ride the demand-driven lock-owner relay pattern: `PresenceRelayService` (`services/presence_relay.py`, default-enabled, 2s tick, registered in `main.py`) recomputes ONE global online set (dots + roster from the same set, so they never disagree) with **hysteresis** (`presence.stays_online`: online at `PRESENCE_TIMEOUT_SECONDS`, drops only after `+ PRESENCE_ONLINE_MARGIN_SECONDS` grace - quick on, slow off - to kill boundary flicker), batch-reads via `get_users_by_uids`, and publishes `{online, last_seen}` to `public.presence.{uid}` **only on change** (an `online`-bool transition or a first-seen subscriber) - never on a fixed interval, so an idle page emits nothing. Frontend `PresenceManager` (`static/js/PresenceManager.js`, `app.presence`) scans `[data-presence-uid]` elements, subscribes via `app.pubsub`, and treats each frame's `online` flag as **authoritative** (so a live dot never false-expires from the client clock); the `last_seen` staleness timer is only a fallback for elements that never got a frame (guests). **The old messaging presence was refactored onto this**: the per-worker, WS-connect-based `message_hub.is_online/last_seen` display path was removed (messaging kept only its socket connection tracking for delivery); the messages header presence span now carries `data-presence-uid` and is driven by the same `PresenceManager`, so chat presence is finally cross-worker correct. Reuse `presence.is_online` / the `public.presence.{uid}` topic / `PresenceManager` for any online indicator - never re-implement WS-connect presence. A **sitewide avatar corner dot** (green online / grey offline) reuses all of this via ONE partial `templates/_presence_dot.html` (a `data-presence-uid` span with no label, coloured by `PresenceManager` with no extra JS), included by `templates/_avatar_link.html` and the raw-avatar sites wrapped in `.avatar-badge`. The feed's **Online now** panel is the one place presence is queried by `last_seen` (`presence.online_users`/`online_candidates` -> `database.get_online_users`, indexed by `idx_users_last_seen`, ordered **alphabetically by username** so avatars keep a stable slot): the same relay republishes the shared `public.presence.roster` topic only when the online SET changes (a `frozenset` compare - reordering never republishes), and `static/js/OnlineUsers.js` (`app.onlineUsers`) renders it live. See AGENTS.md -> "Online presence".
|
||||
@@ -283,7 +283,7 @@ A second REST surface mounted at `/api` that mimics the public devRant API on De
|
||||
- **Admin seniority: a junior admin cannot manage a more senior admin.** Every per-user mutation in `routers/admin/users.py` (role/password/toggle/reset-ai-quota) is gated by `_is_senior_admin(actor, target)` - it blocks (audits `result="denied"`) when the target is an Admin who registered earlier (`(created_at, id)` ascending, matching `get_primary_admin_uid()`). Members and junior admins stay manageable; the guard is server-side so it also covers Devii's admin tools. See AGENTS.md -> "Admin seniority guard".
|
||||
- **Never pass a `respond()` context key that collides with a Jinja global.** `respond(request, template, ctx, model=XOut)` feeds the **same** `ctx` dict to both the Pydantic model (JSON) and the template render. Jinja context variables shadow `env.globals` across the whole inheritance chain, so a key like `is_admin`/`avatar_url`/`format_date`/`is_self`/`owns`/`guest_disabled` holding a non-callable value turns `base.html`'s `{% if is_admin(user) %}` into `False(user)` -> `TypeError: 'bool' object is not callable`, a 500 that only fires for the branch that calls the global (e.g. logged-in users, not guests). Name viewer/permission flags distinctly (`viewer_is_admin`, not `is_admin`) in both the schema and the context. This was a real issue on `/issues/{number}`; `tests/api/issues/create.py::test_issue_detail_renders_for_{member,admin}` guard it.
|
||||
- **Avatars:** Multiavatar SVG generated locally from the username seed in <5ms, in-memory cached (cleared on restart). URL `/avatar/multiavatar/{seed}?size={size}`. Falls back to initial-based SVG on error.
|
||||
- **Project visibility (`is_private`) and read-only (`read_only`)** are owner-controlled integer flags on the `projects` row (see `AGENTS.md` -> "Project visibility and read-only"). Two invariants: (1) private-read access is gated by the single `content.can_view_project(project, user)` predicate at EVERY read surface - detail, file routes (`_load_viewable_project`), zip, listing, profile, sitemap - never re-implement the check inline. The predicate is `not is_private OR is_owner OR (is_admin AND owner is not an admin)`: a project hidden by a **member** stays visible to any admin, but a project hidden by an **admin** is visible only to that owner admin - **other admins are blocked**. This extends to **containers and terminals**: every per-project container route (`routers/projects/containers/` via `project_for(slug, user)`), the exec-WS PTY terminal, the admin **Containers** manager (`routers/admin/containers.py` listing + `_viewable_instance_or_404` + create + project-search), and the Devii container tools (`ContainerController._project`) gate on `can_view_project(project_of_instance, user)`, so an admin can neither see, start, exec, nor manage a container attached to another admin's hidden project. `get_projects_list` mirrors this for the `/projects` listing (admins are filtered against `database.get_admin_uids()`). Deliberate exclusions: the opt-in public ingress `/p/{slug}` stays anonymous/public, and the raw DB API `/dbapi` is unchanged with respect to project visibility (it is itself restricted to the primary administrator or the internal key). (2) read-only is enforced as a single data-layer guard `project_files._guard_writable(project_uid)` at the top of every mutation entrypoint in `project_files.py`, so it covers the HTTP routes, Devii (via the routes), and container sync at once - add the guard to any NEW file-mutating function, and never enforce read-only only in a route. Devii may flip read-only or visibility (private/public) only after explicit user confirmation, enforced by `dispatcher.confirmation_error` (the `CONFIRM_REQUIRED` set) before the HTTP call; mirror that pattern for any future irreversible agent action. Both owner toggle buttons (`project_detail.html`) likewise carry `data-confirm` so the human UI gates them through `app.dialog` too. **Deletions ALWAYS require confirmation:** `confirmation_error` gates EVERY content delete tool via the `CONFIRM_REQUIRED` set - `delete_post`, `delete_comment`, `delete_gist`, `delete_project`, `project_delete_file`, `delete_media`, `delete_attachment`, `admin_delete_news` - plus `container_instance_action` with `action="delete"` and any `container_exec` whose command matches `dispatcher.DESTRUCTIVE_COMMAND` (rm/rmdir/unlink/shred/truncate/dd/mkfs/wipefs, `find -delete`, redirect over a file, SQL `drop`/`delete from`). The first call is refused with a message; the agent must show the user the exact target and only then pass `confirm=true` (the `DELETING IS ALWAYS CONFIRMED` system-prompt rule mirrors this). Any new name added to `CONFIRM_REQUIRED` is auto-confirmed by a generic fallback in `confirmation_error`. **Load-bearing: every confirmation-gated tool MUST also declare a `confirm` boolean param in its catalog spec** (use the `confirm()` helper in `catalog.py`, or `arg("confirm", ..., kind="boolean")` for container actions). Tool schemas set `additionalProperties: false`, so a gated tool WITHOUT a declared `confirm` param can never receive `confirm=true` from the model - the gate refuses every call and the agent loops forever (this was a real issue on all the delete tools). The param is harmlessly forwarded to the HTTP endpoint (the routes do not declare it; FastAPI ignores undeclared form fields) and ignored by the LOCAL container/customization controllers. The delete tools stay `requires_auth` (not `requires_admin`) - members delete their own, the endpoint's owner-or-admin guard lets admins delete any.
|
||||
- **Project visibility (`is_private`) and read-only (`read_only`)** are owner-controlled integer flags on the `projects` row (see `AGENTS.md` -> "Project visibility and read-only"). Two invariants: (1) private-read access is gated by the single `content.can_view_project(project, user)` predicate at EVERY read surface - detail, file routes (`_load_viewable_project`), zip, listing, profile, sitemap - never re-implement the check inline. The predicate is `not is_private OR is_owner OR (is_admin AND owner is not an admin)`: a project hidden by a **member** stays visible to any admin, but a project hidden by an **admin** is visible only to that owner admin - **other admins are blocked**. **Containers have their own, stricter isolation predicates** (`content.py`: `owns_instance`, `can_view_project_containers`, `can_view_instance`, `can_manage_instance`, all reusing `is_primary_admin`): the **primary administrator** (earliest-created Admin) sees and manages every container including those on private projects; any other admin can VIEW containers of others only when the instance's project is public (private-project containers of others are invisible - of private containers they see only their own) and can MANAGE (edit/lifecycle/exec/terminal/sync/delete/schedules) only instances they own (creator via `created_by`, or project owner) - non-owners get 403 + an audit row with `result="denied"` (WS close 1008). Enforced at every entrypoint: `routers/projects/containers/` (`project_for` + `manage_guard` + the exec-WS), the admin **Containers** manager (`_decorate` filtering + per-row `can_manage`, `_viewable_instance_or_404`, `_manage_denied`, create + project-search), the Devii container tools (`ContainerController._project` + `_require_manage`), and the live view relay (broadcast topics never carry private-project containers; `container.list` publishes public-only frames with `partial: true`). `get_projects_list` mirrors this for the `/projects` listing (admins are filtered against `database.get_admin_uids()`). Deliberate exclusions: the opt-in public ingress `/p/{slug}` stays anonymous/public, and the raw DB API `/dbapi` is unchanged with respect to project visibility (it is itself restricted to the primary administrator or the internal key). (2) read-only is enforced as a single data-layer guard `project_files._guard_writable(project_uid)` at the top of every mutation entrypoint in `project_files.py`, so it covers the HTTP routes, Devii (via the routes), and container sync at once - add the guard to any NEW file-mutating function, and never enforce read-only only in a route. Devii may flip read-only or visibility (private/public) only after explicit user confirmation, enforced by `dispatcher.confirmation_error` (the `CONFIRM_REQUIRED` set) before the HTTP call; mirror that pattern for any future irreversible agent action. Both owner toggle buttons (`project_detail.html`) likewise carry `data-confirm` so the human UI gates them through `app.dialog` too. **Deletions ALWAYS require confirmation:** `confirmation_error` gates EVERY content delete tool via the `CONFIRM_REQUIRED` set - `delete_post`, `delete_comment`, `delete_gist`, `delete_project`, `project_delete_file`, `delete_media`, `delete_attachment`, `admin_delete_news` - plus `container_instance_action` with `action="delete"` and any `container_exec` whose command matches `dispatcher.DESTRUCTIVE_COMMAND` (rm/rmdir/unlink/shred/truncate/dd/mkfs/wipefs, `find -delete`, redirect over a file, SQL `drop`/`delete from`). The first call is refused with a message; the agent must show the user the exact target and only then pass `confirm=true` (the `DELETING IS ALWAYS CONFIRMED` system-prompt rule mirrors this). Any new name added to `CONFIRM_REQUIRED` is auto-confirmed by a generic fallback in `confirmation_error`. **Load-bearing: every confirmation-gated tool MUST also declare a `confirm` boolean param in its catalog spec** (use the `confirm()` helper in `catalog.py`, or `arg("confirm", ..., kind="boolean")` for container actions). Tool schemas set `additionalProperties: false`, so a gated tool WITHOUT a declared `confirm` param can never receive `confirm=true` from the model - the gate refuses every call and the agent loops forever (this was a real issue on all the delete tools). The param is harmlessly forwarded to the HTTP endpoint (the routes do not declare it; FastAPI ignores undeclared form fields) and ignored by the LOCAL container/customization controllers. The delete tools stay `requires_auth` (not `requires_admin`) - members delete their own, the endpoint's owner-or-admin guard lets admins delete any.
|
||||
|
||||
## Modal pattern
|
||||
|
||||
|
||||
Reference in New Issue
Block a user