From bea1c0a183a886c5b1672cc55d0663361605c1b0 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Sun, 9 Aug 2026 15:19:08 +0000 Subject: [PATCH 01/56] Add Radio Studio design spec Design spec for AI-assisted SDR/radio analysis studio (Sparky pattern). Covers studio app layout, rf-scanning skills, Hermes/ARM prism blocker, SDR USB passthrough, receive-only defaults, and Phase 1 cut. --- docs/design/radio-studio.md | 504 ++++++++++++++++++++++++++++++++++++ 1 file changed, 504 insertions(+) create mode 100644 docs/design/radio-studio.md diff --git a/docs/design/radio-studio.md b/docs/design/radio-studio.md new file mode 100644 index 000000000..3ac000214 --- /dev/null +++ b/docs/design/radio-studio.md @@ -0,0 +1,504 @@ +# Radio Studio — AI-assisted SDR / Radio Analysis Studio + +**Date:** 2026-08-09 +**Status:** Draft +**Amended:** 2026-08-09 — initial design spec derived from the community +'Sparky' setup (Hermes agent + HackRF) and the taOS studio pattern. + +## Reference + +- Pattern source: https://github.com/h00nigan/sparky-setup-guide/blob/master/Sparky-Setup-Guide.md + (Hermes agent, HackRF CLI tools, swept spectrum, continuous scan, alert-on-new-signal, + high-res 'staring' analysis, ATC audio playback) +- Existing taOS studio pattern: `desktop/src/apps/codingstudio/`, `designstudio/`, + `musicstudio/` (registered in `desktop/src/registry/app-registry.ts`, tier 5 optional) +- taOS skills system: `docs/design/skills-plugins.md`, `tinyagentos/skills.py` +- Hardware auto-detect pattern: `tinyagentos/hardware.py`, `docs/design/hailo-llm-backend.md` +- Container USB passthrough: `tinyagentos/containers/lxc.py`, `tinyagentos/containers/docker.py` + +## Overview + +Radio Studio is an **optional, tier 5 studio app** (like Coding Studio, Design Studio) +that turns taOS into an AI-assisted SDR / radio analysis workstation. It follows the +established studio pattern: a canvas view, supporting panels, a scan log, a signal +library, an alert feed, and an agent chat pane beside the canvas. + +The target workflow mirrors the community 'Sparky' setup: + +1. Detect an SDR device (HackRF One, RTL-SDR, Airspy, SDRplay) connected via USB. +2. Run a spectrum survey / sweep from within the app, driven by an assigned agent. +3. Surface results in a live waterfall and spectrum view. +4. Persist detected signals in a signal library. +5. Alert the user (and the assigned agent) when a new signal appears. +6. Let the agent drive follow-up analysis: targeted sweeps, audio capture, demodulation. + +The default posture is **receive-only analysis**. Decoding / transmitting is out of scope +for Phase 1 and is gated behind explicit user opt-in and jurisdiction checks. + +## Goals + +- Make SDR a first-class taOS hardware class, auto-detected at boot and on USB insert, + alongside NPU, GPU, and disk. +- Map rf-scanning operations to the canonical taOS skill model so any supported agent + framework can drive them. +- Ship a studio app that a user can install from the Store and open in one click. +- Alert the user when a previously unseen signal appears in a watched band. + +## Non-goals + +- Transmit capability. Radio Studio ships receive-only. TX requires separate legal + review, licensing checks, and hardware enforcement (e.g. HackRF TX enable flag). +- Decoding / demodulation in Phase 1. Signal detection and classification only. + Demodulation (FM, AM, digital modes) is a follow-up. +- Signal fingerprinting / library sharing between taOS instances. +- Multi-SDR load balancing or networked SDR pools. +- Mobile / tablet UI. Studio apps are desktop-first. + +## Hardware: SDR Auto-Detection + +### Detection model + +SDR detection follows the same zero-touch pattern as NPU / Hailo detection +(`tinyagentos/hardware.py`). A new `SdrInfo` dataclass is added: + +```python +@dataclass +class SdrInfo: + type: str = "" # hackrf | rtl-sdr | airspy | sdrplay | soapy | unknown + device: str = "" # /dev/bus/usb/... or sysfs path + serial: str = "" # device serial when available + driver: str = "" # soapy, hackrf, rtl-sdr, etc. + max_sample_rate: int = 0 +``` + +`HardwareProfile` gains an `sdr: SdrInfo = field(default_factory=SdrInfo)` attribute +and `profile_id` gains an `-sdr` suffix when an SDR is present (e.g. +`x86-cuda-16gb-sdr`). + +### Detection methods + +| Device | Primary probe | Fallback / notes | +|---|---|---| +| HackRF One | `hackrf_info` CLI returns `Found HackRF One` | `lsusb -d 1d50:6049`, `/sys/bus/usb/devices/*/idVendor` + `idProduct` | +| RTL-SDR | `rtl_test -d 0` succeeds | `lsusb -d 0bda:2838` | +| Airspy | `airspy_info` CLI | `lsusb` vendor/product match | +| SDRplay | `sdrplay_apiService` running or `mirsdri` binary | Windows/macOS path, not the primary Linux target | +| SoapySDR | `SoapySDRUtil --find` returns devices | Universal fallback for any Soapy-supported device | + +### Runtime re-detection + +Like USB storage, SDRs can be hot-plugged. The existing hardware detection loop +(or a new USB watcher thread) re-runs `detect_hardware()` on udev `add/remove` +events for USB devices. When a new SDR appears, the OS surfaces a notification +and the Radio Studio app offers to open. + +### Container passthrough + +The agent container must see the raw USB device. Two backends: + +**LXC / Incus** (`tinyagentos/containers/lxc.py`): +``` +incus config device add sdr0 usb \ + vendorid=0x1d50 productid=0x6049 +``` + +**Docker / Podman** (`tinyagentos/containers/docker.py`): +``` +docker run --device=/dev/bus/usb/... +``` + +The container runtime backend exposes a new `usb_devices` argument on +`create_container()` (mirroring the existing `mounts` argument). The SDR +detector returns the matching `vendorid` / `productid` and the app orchestrator +adds the device when deploying an agent that has rf-scanning skills assigned. + +## Agent Framework Support + +### Hermes + +The community 'Sparky' setup uses Hermes as the radio agent. taOS already supports +Hermes (installer: `tinyagentos/scripts/install_hermes.sh`, bridge adapter in +`docs/design/framework-agnostic-runtime.md`). rf-scanning skills register as +Hermes functions via the same adapter path used for `web_search`, `browser_control`, +etc. + +Hermes skill injection uses the Hermes `functions` config key. The Skill Injector +(`docs/design/skills-plugins.md §Skill Injector`) maps each assigned rf-scanning +skill's `tool_schema` into Hermes's function-calling format. + +### Hermes ARM / prisma blocker + +**Issue:** Hermes (and other frameworks that use Prisma for session / memory +storage) cannot start on ARM hosts (Pi, RK3588) where Prisma does not ship a +compatible `libquery-engine` binary. This is a known upstream gap, not a taOS bug. + +**Workaround today:** taOS already falls back to the shared LiteLLM master key +on ARM hosts that cannot run Prisma (`TAOS_DISABLE_AGENT_MASTER_KEY_FALLBACK=1` +to opt out). Radio Studio agents on ARM hosts should: + +- Default to OpenClaw or SmolAgents (no Prisma dependency) for rf-scanning tasks. +- Surface a banner in the studio: "Hermes is unavailable on this ARM host + (Prisma engine missing). Use OpenClaw or SmolAgents for the radio agent." +- Continue to support Hermes on x86 hosts where Prisma works. + +**Longer-term fix:** Track upstream Prisma ARM support or replace the session +store with SQLite / QMD so all frameworks work everywhere. This is out of scope +for the Radio Studio spec but must be noted in any Hermes-facing documentation. + +### Other frameworks + +| Framework | rf-scanning skill support | Notes | +|---|---|---| +| Hermes | adapter | Works on x86. ARM blocked by Prisma. | +| OpenClaw | adapter | Recommended default on ARM. | +| SmolAgents | adapter | Recommended default on ARM. | +| PocketFlow | adapter | Skills become callable nodes. | +| Langroid | adapter | Tool registration. | +| OpenAI Agents SDK | adapter | Function tool injection. | + +## rf-scanning Skills + +Skills follow the canonical taOS ops-skills pattern +(`docs/design/skills-plugins.md`, `app-catalog/plugins//manifest.yaml`). + +### Skill manifest format + +```yaml +id: hackrf-spectrum-survey +name: HackRF Spectrum Survey +type: plugin +version: 1.0.0 +category: comms +description: "Sweep a frequency band with HackRF and return power measurements" + +requires: + ram_mb: 0 + hardware: [sdr] + cli_tools: [hackrf_sweep] + +install: + method: script + script: scripts/install-hackrf-tools.sh + module: tinyagentos.tools.rf_scanning.hackrf_spectrum_survey + +tool_schema: + name: hackrf_spectrum_survey + description: "Sweep a frequency range and return FFT bins with power levels" + input_schema: + type: object + properties: + start_hz: + type: integer + description: "Start frequency in Hz" + stop_hz: + type: integer + description: "Stop frequency in Hz" + gain: + type: integer + description: "LNA / VGA gain" + bin_width_hz: + type: integer + default: 100000 + required: [start_hz, stop_hz] + +frameworks: + hermes: adapter + openclaw: adapter + smolagents: adapter + pocketflow: adapter + langroid: adapter + openai-agents-sdk: adapter + +hardware_tiers: + x86-cuda-16gb: full + arm-npu-8gb: full + cpu-only: full +``` + +### Phase 1 skills + +Only one skill ships in Phase 1: + +| Skill ID | Description | CLI dependency | +|---|---|---| +| `hackrf-spectrum-survey` | Sweep a frequency range, return FFT bins with timestamps and power levels | `hackrf_sweep` | + +Phase 2+ skills (out of scope for this spec): + +| Skill ID | Description | +|---|---| +| `hackrf-targeted-sweeps` | Targeted sweep around a known frequency with higher resolution | +| `sdr-audio-capture` | Capture baseband / demodulated audio to WAV | +| `sniffing` | Narrow-band capture for protocol analysis (e.g. 433 MHz OOK, sub-GHz) | + +### CLI tool installation + +`hackrf_sweep` ships in the `hackrf` package on Debian/Ubuntu. A taOS install +script (`scripts/install-hackrf-tools.sh`) checks for the binary and installs +it via `apt install hackrf`. On ARM hosts it warns that HackRF USB 3.0 throughput +may be limited by the host controller. + +## Radio Studio App + +### Registration + +```typescript +// desktop/src/registry/app-registry.ts +{ + id: "radio-studio", + name: "Radio Studio", + icon: "radio", + category: "studio", + component: () => import("@/apps/RadioStudioApp").then((m) => ({ default: m.RadioStudioApp })), + defaultSize: { w: 1200, h: 800 }, + minSize: { w: 800, h: 600 }, + singleton: true, + pinned: false, + launchpadOrder: 13.33, + optional: true, + tier: 5 +} +``` + +### File structure + +``` +desktop/src/apps/RadioStudioApp/ + RadioStudioApp.tsx # Top-level studio shell + types.ts # Signal, Scan, Alert, SpectrumSnapshot + useRadioStore.ts # Local state (signals, alerts, scan log) + SpectrumView.tsx # Waterfall + spectrum canvas (WebGL / Canvas 2D) + ScanLog.tsx # Chronological scan log table + SignalLibrary.tsx # Saved signals / bookmarks + AlertFeed.tsx # Notifications + alert list + AgentChat.tsx # Agent chat pane beside canvas + SdrStatusBar.tsx # Device status, sample rate, gain, USB link speed + api.ts # Backend API calls (/api/radio-studio/*) +``` + +### Layout + +``` ++------------------------------------------------------------------+ +| Radio Studio — SDR _ X| ++------------------------------------------------------------------+ +| Spectrum View (canvas) | Scan Log | Signal Library | +| - Waterfall (time vs freq) | - Timestamp | - Saved signals | +| - Spectrum line (current FFT) | - Freq span | - Notes / tags | +| - Cursor / click to tune | - Peaks | - Demod actions | +| | - Agent log | | ++----------------------------------+-------------+-------------------+ +| Alert Feed (bottom strip) | +| [NEW] 144.390 MHz APRS — first seen 2s ago [Dismiss] | ++------------------------------------------------------------------+ +| Agent Chat | +| Sparky: Running survey 1 MHz - 6 GHz... | +| > alert-on-new-signal --band 118-137 MHz --threshold -60dBm | ++------------------------------------------------------------------+ +``` + +### Views + +**Spectrum View (primary canvas)** +- Waterfall: frequency on X axis, time scrolling down Y axis, colour = power (dBm). +- Spectrum line: latest FFT drawn as a line chart overlaid on the waterfall. +- Click / drag to set a new scan range. Double-click to center and zoom. +- Cursor readout: frequency, power, mode guess (AM / FM / narrow-band). + +**Scan Log** +- Each scan run produces one entry: timestamp, span, step, gain, peak count. +- Clicking an entry re-runs the scan or loads the cached result. +- Agent-driven scans are tagged with the agent slug that triggered them. + +**Signal Library** +- User or agent bookmarks a signal: frequency, bandwidth, modulation guess, + first-seen, last-seen, notes. +- Persisted in project files under `projects//files/signals/`. + +**Alert Feed** +- System notifications (desktop bell) plus in-app strip. +- "New signal" alert: frequency appeared in a watched band that was previously + empty or below threshold. +- Configurable threshold, debounce (avoid alerting on the same carrier 60 times + per second), and muted frequencies. + +**Agent Chat** +- Standard chat pane, scoped to the studio's agent. +- Slash commands: `/survey 1-6G`, `/stare 144.39M`, `/alert 118-137M -60`, + `/capture 30s 144.39M`. +- Agent can push spectrum snapshots and signal cards into the chat as images / + structured data. + +### Backend routes + +``` +GET /api/radio-studio/status — SDR connected? model? sample rate? +POST /api/radio-studio/survey — run hackrf_spectrum_survey skill +GET /api/radio-studio/survey/{id} — cached result (FFT bins, peaks) +POST /api/radio-studio/alert/watch — add frequency band to watch list +DELETE /api/radio-studio/alert/watch/{id} — remove band +GET /api/radio-studio/alerts — recent new-signal alerts +GET /api/radio-studio/signals — saved signal library +POST /api/radio-studio/signals — save / bookmark signal +``` + +## Receive-only Defaults + Jurisdiction Disclaimer + +### Default posture + +Radio Studio ships in **receive-only** mode: + +- TX is disabled in the UI and the backend rejects any skill call with a + `transmit` flag. +- The HackRF CLI is invoked without `-t` (TX) arguments. `hackrf_transfer` is + never called by any Phase 1 skill. +- An SDR device is opened with read-only intent. The container / host policy + enforces this via capability restrictions (no `CAP_NET_ADMIN`, no raw socket + creation, no TX buffer writes). + +### Jurisdiction disclaimer + +Radio spectrum is regulated. The user is solely responsible for complying with +local laws: + +- Receiving certain signals may be restricted (e.g. encrypted services, + emergency services, aviation band in some jurisdictions). +- Transmitting without a license is illegal in most countries. +- The software does not decrypt, decode, or otherwise process payloads in + Phase 1. Classification is limited to signal presence, bandwidth, and + modulation heuristics. + +The Store listing, installer, and in-app onboarding all display: + +> Radio Studio is a receive-only analysis tool. You are responsible for +> complying with local regulations governing radio reception and transmission. +> No decoding or payload processing is performed in Phase 1. + +### Analysis focus + +Phase 1 is explicitly scoped to **spectrum awareness**: + +- What is active? +- Where are the peaks? +- Is there a new signal? +- How does activity change over time? + +Demodulation, decoding, protocol identification, and payload inspection are +explicitly out of scope for Phase 1 and are not represented in the UI or skills. + +## Phase 1 Cut + +### What ships + +| Feature | Detail | +|---|---| +| SDR detection | `SdrInfo` in `HardwareProfile`; USB hot-plug re-detection | +| USB passthrough | LXC `usb` device add + Docker `--device`; wired into agent deploy | +| One survey skill | `hackrf-spectrum-survey` (`hackrf_sweep`) | +| Spectrum snapshot | Waterfall + FFT line in the studio canvas | +| Alert on new signal | Watched band monitor; new carrier > threshold triggers alert + notification | +| Agent chat | Standard studio chat pane; slash commands for radio ops | + +### What does not ship in Phase 1 + +- Targeted sweeps, audio capture, sniffing skills. +- Signal demodulation or decoding. +- Hermes on ARM (Prisma blocker — use OpenClaw / SmolAgents). +- Multi-SDR support (one device per host). +- RTL-SDR / Airspy backend support (HackRF is the reference device; others + follow the same detection + passthrough pattern). +- Cross-instance signal library sync. + +### Success criteria + +- A user plugs in a HackRF One, opens Radio Studio, and sees the device status + bar show `HackRF One / 20 MSPS`. +- The user runs a survey (via agent chat or UI button) and sees a waterfall + populate within seconds. +- Adding a band to the watch list produces a desktop notification when a new + carrier appears. + +## Architecture + +``` +Desktop (React) + └── Radio Studio app (desktop/src/apps/RadioStudioApp/) + ├── SpectrumView.tsx — waterfall + FFT canvas + ├── ScanLog.tsx — scan history + ├── SignalLibrary.tsx — bookmarks + ├── AlertFeed.tsx — new-signal alerts + └── AgentChat.tsx — Hermes / OpenClaw chat + +Backend (FastAPI) + └── /api/radio-studio/* + └── skill dispatch → hackrf-spectrum-survey skill + +Skill runtime + └── tinyagentos/tools/rf_scanning/hackrf_spectrum_survey.py + └── subprocess hackrf_sweep → parse CSV → return FFT bins + +Hardware + └── tinyagentos/hardware.py::detect_hardware() + └── _detect_sdr() → SdrInfo (vendor/product, serial, driver) + +Container + └── LXC / Docker backend + └── USB device passthrough (vendorid/productid) +``` + +## File Map (Phase 1) + +``` +desktop/src/apps/RadioStudioApp/ + RadioStudioApp.tsx + types.ts + useRadioStore.ts + SpectrumView.tsx + ScanLog.tsx + SignalLibrary.tsx + AlertFeed.tsx + AgentChat.tsx + SdrStatusBar.tsx + api.ts + +tinyagentos/ + hardware.py # + SdrInfo, _detect_sdr() + containers/ + lxc.py # + usb device support + docker.py # + --device passthrough + tools/ + rf_scanning/ + __init__.py + hackrf_spectrum_survey.py # Phase 1 skill implementation + routes/ + radio_studio.py # /api/radio-studio/* routes + +app-catalog/plugins/ + hackrf-spectrum-survey/ + manifest.yaml + +scripts/ + install-hackrf-tools.sh # hackrf_sweep, hackrf_info + +tests/ + test_hardware.py # + SDR detection tests + test_radio_studio.py # API + skill tests + test_rf_scanning.py # hackrf_spectrum_survey unit tests +``` + +## Dependencies + +- `hackrf` (Debian/Ubuntu package) — provides `hackrf_sweep`, `hackrf_info`. +- Existing taOS skill runtime, container backends, hardware detection, and + desktop shell (no new framework dependencies). + +## Risks + +- **HackRF USB 3.0 throughput on ARM hosts.** The Pi 4/5 USB controller may + struggle with 20 MSPS sustained. Phase 1 gates on a warning; Phase 2 can + add sample-rate throttling. +- **Prisma ARM blocker** prevents Hermes on Pi / RK3588. Workaround is to + default to OpenClaw / SmolAgents on ARM. Long-term fix is upstream. +- **Legal / regulatory.** The receive-only default and jurisdiction disclaimer + mitigate but do not eliminate risk. Store listing and installer must carry + the disclaimer prominently. From 782ac24d998bb3a388f2e421df02c8d51d0238d8 Mon Sep 17 00:00:00 2001 From: hognek Date: Sun, 9 Aug 2026 18:53:32 +0200 Subject: [PATCH 02/56] fix(theme-store): use injected get() to break circular self-reference (#2288) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(projects): doc-review stamp store + routes (#1802 slice 3) (#1835) * fix(projects): show consent-flow external agents in the External section (#1784) Approved external CLI agents (grok, kilo) were appearing in the plain Members list instead of under "External / Connected agents" next to the other connected agents. The Members panel only classified a member as external when its member_id matched a registry agent's handle, but the consent flow registers these agents with an empty handle and adds the project member row keyed by the canonical id. So the match never fired and they fell through to the main list. Match external registry agents by canonical id as well as handle (older identities like the assistant reference by handle, consent-flow agents by canonical id), and map the "grok" framework, not only "grok-build", to the Grok label so the badge reads correctly. * test(secrets): add coverage for the Secrets app (#1785) Covers the mount-time /api/secrets fetch and loading state, masked value rendering, the empty and failed-fetch fallbacks, reveal and hide via the per-secret API, add and delete through the dialog, and category filtering. The GitHub integration is mocked so its on-mount identity fetch does not interfere with the secrets assertions. * test(notes): add vitest coverage for NotesApp/TodoApp mounted behavior (#1787) Render NotesApp and TodoApp, mock the /api/notes fetch on mount, and assert real behavior: kind filtering, empty states, detail load on select, and the create flow. * test(chess): add vitest coverage for ChessApp (#1788) Cover render, legal moves, turn changes, checkmate status, new game reset, and vs-agent mode, with the on-mount agents fetch mocked. * test(imageviewer): add vitest coverage for ImageViewerApp (#1789) Render the app and assert real behavior: empty state, file load, zoom in/out with min/max clamps, 90-degree rotation, reset on new image, and object URL revocation. Stub fetch and URL.createObjectURL so on-mount integrations do not interfere. * fix(desktop): Registry poll no longer resets scroll (#1761) (#1786) * fix(desktop): keep Registry panel scroll stable across 5s polls (#1761) Quiet background polls no longer flip loading (which unmounted the list) and setEntries is a no-op when id/content are unchanged, so scroll and in-progress interaction are preserved. Add registryEntriesEqual helper and vitest coverage for the poll no-op path. * fix(desktop): guard registryEntriesEqual index access and drop em dashes The poll no-op comparison read a[i]/b[i] without a guard, which fails the strict noUncheckedIndexedAccess build (spa-build). Add an explicit undefined guard, and remove the em dashes from the added comments. * chore(deps): bump the python-deps group with 3 updates (#1790) Updates the requirements on [uvicorn[standard]](https://github.com/Kludex/uvicorn), [croniter](https://github.com/pallets-eco/croniter) and [litellm[proxy]](https://github.com/BerriAI/litellm) to permit the latest version. Updates `uvicorn[standard]` to 0.51.0 - [Release notes](https://github.com/Kludex/uvicorn/releases) - [Changelog](https://github.com/Kludex/uvicorn/blob/main/docs/release-notes.md) - [Commits](https://github.com/Kludex/uvicorn/compare/0.50.0...0.51.0) Updates `croniter` from 6.2.3 to 6.2.4 - [Release notes](https://github.com/pallets-eco/croniter/releases) - [Changelog](https://github.com/pallets-eco/croniter/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pallets-eco/croniter/compare/6.2.3...6.2.4) Updates `litellm[proxy]` to 1.92.0 - [Release notes](https://github.com/BerriAI/litellm/releases) - [Commits](https://github.com/BerriAI/litellm/commits) --- updated-dependencies: - dependency-name: uvicorn[standard] dependency-version: 0.51.0 dependency-type: direct:production dependency-group: python-deps - dependency-name: croniter dependency-version: 6.2.4 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: python-deps - dependency-name: litellm[proxy] dependency-version: 1.92.0 dependency-type: direct:production dependency-group: python-deps ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump the spa-deps group in /desktop with 16 updates (#1791) --- updated-dependencies: - dependency-name: "@codemirror/state" dependency-version: 6.7.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: spa-deps - dependency-name: "@codemirror/view" dependency-version: 6.43.6 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: spa-deps - dependency-name: "@radix-ui/react-dialog" dependency-version: 1.1.19 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: spa-deps - dependency-name: "@radix-ui/react-dropdown-menu" dependency-version: 2.1.20 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: spa-deps - dependency-name: "@radix-ui/react-select" dependency-version: 2.3.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: spa-deps - dependency-name: "@radix-ui/react-switch" dependency-version: 1.3.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: spa-deps - dependency-name: "@radix-ui/react-tabs" dependency-version: 1.1.17 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: spa-deps - dependency-name: "@radix-ui/react-tooltip" dependency-version: 1.2.12 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: spa-deps - dependency-name: "@tiptap/extension-link" dependency-version: 3.27.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: spa-deps - dependency-name: "@tiptap/extension-underline" dependency-version: 3.27.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: spa-deps - dependency-name: "@tiptap/pm" dependency-version: 3.27.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: spa-deps - dependency-name: "@tiptap/react" dependency-version: 3.27.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: spa-deps - dependency-name: "@tiptap/starter-kit" dependency-version: 3.27.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: spa-deps - dependency-name: "@types/three" dependency-version: 0.185.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: spa-deps - dependency-name: vite dependency-version: 8.1.4 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: spa-deps - dependency-name: vitest dependency-version: 4.1.10 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: spa-deps ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * docs(design): account model, free username plus paid chosen subdomains (#1792) * docs(design): Hailo-10H LLM backend, zero-touch install parity with RK3588 (#1793) * docs(design): hub.taos.my local-first P2P social network foundation (#1794) * feat(hailo): reserve port 7836 and map hailo-ollama llm-chat capability (#1795) Slice 1 of the Hailo-10H LLM backend design (docs/design/hailo-llm-backend.md): add 7836 to RESERVED_PORTS so apps cannot squat the NPU backend port, and register hailo-ollama with llm-chat in BACKEND_CAPABILITIES. Closes the first shippable piece of #1771. * feat(account): controller proxy actions for subdomain check/claim/release (slice 3) (#1796) * feat(account): proxy subdomain check/claim/release actions (slice 3) Add /api/account/subdomains/{check,claim,release} routes to account_proxy.py that forward to the taos.my subdomain claims service with the session cookie passthrough. The name field is validated rid-style before it can reach the upstream URL, so a crafted name cannot inject path/query (SSRF/path-traversal guard). Implements account design doc slice 3. * test(account): cover subdomain proxy forwarding, 503, and name validation Add tests alongside the existing account_proxy suite: forwarding of check (query name) and claim/release (body name) with cookie passthrough, 503 when the account service is unconfigured, and 400 on an invalid name with no upstream call. * docs(design): Projects app nested elements, one project with typed elements (#1797) * fix(models): VRAM reservation TTL sweep + #1766 acceptance coverage (#1798) Completes the remaining #1766 work after the #1767 hotfix (fail-open on no-probe hardware, backend-level min_ram_mb gate, asyncio.to_thread probe). - Reclaim VramReservation entries older than a configurable TTL (default 1h) so a hung installer cannot hold capacity until controller restart. Sweep runs from reserve(), available_vram(), stats(), and public sweep_stale(). - Extract _estimated_vram_mb() so the rkllama pull gate clearly reads requires.backends[].min_ram_mb (max across backends, variant fallback). - Tests: no-probe + real backend min_ram proceeds (no 503), concurrent large reserves on NVIDIA still atomic, TTL reclaim, and route-level 503 when free VRAM is measurable and insufficient. Closes #1766 * feat(account): frontend types + Account panel split for username/subdomains (slice 4) (#1801) Implements slice 4 of docs/design/account-username-subdomain-model.md. - account-client: add SubdomainClaim/SubdomainCheck types, Account.username and Account.subdomains, deprecate Account.handle; add checkSubdomain, claimSubdomain, releaseSubdomain helpers with the same degrade-to-state (AuthError, never throw) error style as the auth actions. - AccountPanel: split the old ReserveHandleCard into a free UsernameCard (no taOSgo mention, no .taos.my suffix) and a SubdomainsCard (claim list with active/grace badges, inline availability check, release, disabled claim UI when unsubscribed). Update the section intro copy. - Tests: account-client subdomain helper coverage (mocked website endpoints); AccountPanel coverage for free-username copy, claim list rendering, disabled claim when unsubscribed, and grace badge. * feat(hailo): slice 2 hailo-ollama installer (#1771) (#1803) * feat(hailo): slice 2 hailo-ollama installer (#1771) Implements docs/design/hailo-llm-backend.md slice S2 (section C): scripts/install-hailo.sh mirrors scripts/install-rknpu.sh structure and safety contract. Detects Hailo-10H via /dev/hailo0 + lspci/hailortcli, installs HailoRT (>= 5.1.0 firmware floor) on Raspberry Pi OS, clones hailo-ollama at a pinned ref remapped to port 7836, installs a systemd unit with orphan-reap ExecStartPre, and health-waits on /api/tags. Verification: bash -n, shellcheck, and a non-Hailo host prints the no-detection notice and exits 0 without touching the system. * chore(hailo): doc-gate trailer for install-hailo.sh The installer is specified line by line in docs/design/hailo-llm-backend.md section C (slice S2), which is already merged on dev. Docs-Reviewed: implements the merged design doc docs/design/hailo-llm-backend.md section C, no separate doc change needed * feat(hailo): slice 3 hailo-ollama managed service manifest (#1804) * feat(hailo): slice 3 hailo-ollama managed service manifest Add app-catalog/services/hailo-ollama/manifest.yaml per the managed-backend contract (lifecycle.auto_manage, unit, scope=system, health on 7836). The backend flows through load_managed_backends() with no new plumbing. Adds a unit test that loads the real manifest and asserts the backend is returned by load_managed_backends(). Docs-Reviewed: implements the merged design doc docs/design/hailo-llm-backend.md * fix(hailo): declare the proprietary license in the hailo-ollama manifest test_services_manifests requires a license key on every service manifest. The runtime is Hailo proprietary software behind an install-time EULA acceptance, so the manifest now says exactly that. Docs-Reviewed: license posture is specified in docs/design/hailo-llm-backend.md security and licensing section * feat(account): onboarding free username claim step (slice 5) (#1805) Adds a free taOS username claim step to OnboardingScreen, shown right after local account creation. Clearly labeled free, never gated behind taOSgo, and the user can always finish (claim is optional and failures degrade to a Settings pointer). Public subdomain publishing is deferred to Settings so onboarding never dead-ends on the paid path. Bounded to OnboardingScreen.tsx plus its test, per the slice plan. * feat(hub): identity keypair keystore + directory registration proxy (slice 1) (#1806) Implements slice 1 of the hub.taos.my own-your-posts social network design (docs/design/hub-social-network-foundation.md). Controller side, the taos.my directory endpoints are the contract (mocked in tests): - tinyagentos/hub/identity.py: node keystore that mints an Ed25519 signing key and an X25519 encryption key on first use, persists them 0600 under /hub/identity.json (mesh_credentials.py pattern: atomic write, allowlisted fields, TAOS_DATA_DIR override), and exposes the registerable public view, the SHA-256 author fingerprint, and a challenge-proof signer plus verifier. - account_proxy.py: _ACTIONS additions and same-origin routes for hub identity register / lookup / rotate, forwarding to /api/hub/identity/* with session cookie pass-through; lookup validates the username as an rid-style token before it can reach the upstream URL. Tests: keystore round-trip + 0600 perms + stable fingerprint, proxy forwarding + 503 (unconfigured and unreachable), challenge proof rejects a wrong key, lookup relays the append-only key log. * feat(hailo): slice 4 install-time gates for Hailo-10H (per design doc) (#1807) Mirror the Rockchip RKNPU install-time gates for the Hailo-10H NPU across install.sh, scripts/install-server.sh and scripts/install-worker.sh: detect /dev/hailo0 with 10H vs 8L discrimination (lspci/hailortcli, with TAOS_FORCE_HAILO override), chain into scripts/install-hailo.sh under TAOS_HAILO_SETUP=1, fail-soft on chain failure, and document the new env vars in each script header. Part of #1771. Docs-Reviewed: implements the merged design doc (doc-gate requires it) * feat(hailo): slice 5 runtime detection + provider adapter for Hailo-10H (per design doc) (#1808) Add the hailo-ollama backend end to end with no new hardware needed for CI: - worker probe candidate on the taOS remap port 7836, Ollama-compatible - OllamaCompatAdapter entry for hailo-ollama - litellm_config ollama-compat membership extended to hailo-ollama - provider type registered so auto_register_from_manifest seeds local-hailo-ollama on Hailo-10H hardware (mirrors rkllama local-rkllama) - tests for detection, LiteLLM ollama/ prefix, and seed * feat(hub): profile object + local hub store (slice 2) (#1809) * feat(hub): profile object + local hub store (slice 2) Implements slice 2 of the hub.taos.my own-your-posts social network design (docs/design/hub-social-network-foundation.md). - tinyagentos/hub/store.py: canonical-JSON encode/hash/sign/verify helpers and a SQLite HubStore (objects, blobs, authors tables). Objects are canonical-JSON encoded, content-addressed by SHA-256 of the canonical bytes excluding the signature, and signed by the slice-1 Ed25519 keystore. Profiles are the one mutable object with highest-version-wins semantics; put_profile ignores a stale (lower-or-equal version) replica so it can never clobber a newer one. - tinyagentos/routes/hub.py: local API the Hub app consumes. Render the node's own profile and create/update it with a version bump, each response carrying an explicit degrade state (no-identity / no-profile / ok). The store is opened lazily and colocated with the identity keystore under the data dir. Wired into routes/__init__.py. No peer networking; directory calls stay in account_proxy. Tests: canonicalization vectors, sign/verify (good sig verifies, tamper and wrong key do not), version-wins, object/blob/author store round-trip, and the profile routes end to end (degrade states, create + version bump, kind validation, signature check). * chore(hub): doc-gate trailer for the hub store slice The profile object and local hub store are specified in docs/design/hub-social-network-foundation.md (slice 2), merged on dev. Docs-Reviewed: implements the merged design doc docs/design/hub-social-network-foundation.md slice 2, no separate doc change needed * feat(hub): follow / friend / circle model + request brokering (slice 3) (#1810) Implement hub social slice 3 from docs/design/hub-social-network-foundation.md: - signed follow and cache-grant statements (cache-grant stored, not yet acted on; the cache worker lands in slice 6), - friend-request send/accept/decline flows that broker through the directory and record the local accepted edge, - local block (severs every edge to the peer and asks the hub to revoke the server-side edge) and mute operations, - a presence gate that denies lookup without an accepted edge, - directory proxy entries for requests, presence, and edge revoke. Tests cover edge authorization (presence denied without an accepted edge), rate-limit behavior, and block severing the edge. Docs-Reviewed: implements the merged design doc (doc-gate requires it) * feat(projects): nested element store, CRUD routes, and task element tags (slice 1) (#1811) * feat(projects): implement nested element store, CRUD routes, and task element tags (slice 1) Adds project elements (one level of nesting per the design) with a dedicated store and owner-gated CRUD routes, an element_id tag on tasks with create and update validation plus list and ready filtering, and the Beads snapshot carrying the tag. Group/promote and assignment are later slices. Docs-Reviewed: implements the merged design doc docs/design/projects-nested-elements.md slice 1. * test(projects): slice 1 element store, CRUD route, and task tag coverage Adds the element store unit tests and route-level coverage for element CRUD, tag validation, the 409 delete guard, untag mode, and element_id filtering on list/ready. Proves an external agent token filters by element with no auth change. Docs-Reviewed: implements the merged design doc docs/design/projects-nested-elements.md slice 1. * feat(projects): kanban element filter bar (slice 2) (#1812) Add a persistent element axis to the kanban board: element client API and element_id on task types, a pure element filter in boardFiltering, a new ElementFilterBar rendered from the toolbar (All | element chips | Project- level), an element badge on cards when the board is unfiltered, and element fetching wired through useBoardData. Zero-element projects stay untouched (the bar does not render). Tests added and existing board tests updated. Docs-Reviewed: implements the merged design doc (doc-gate requires it for scripts/, app-catalog/, tinyagentos/ changes) * feat(hub): post objects, chain logic, image ingest, composer + own-timeline (slice 4) (#1813) Implements slice 4 of docs/design/hub-social-network-foundation.md: a per-author hash chain (seq/prev), signed append plus verify and tamper detection, signed tombstones that drop content while keeping the chain verifiable, and image ingest that re-encodes and strips EXIF. Adds the local post, timeline, and delete routes plus the Hub app: a composer with a loud friends-only-by-default visibility switch and an own-timeline read from the local store. No peer sync yet (that is slice 5). Tests cover chain append/verify, tamper detection, tombstone drops content and keeps the chain verifiable, and EXIF stripped. Docs-Reviewed: implements the merged design doc (doc-gate requires it for scripts/, app-catalog/, tinyagentos/ changes) * fix: map violet and red note colors to valid tldraw palette names (#1815) Canvas notes with payload.color violet or red fell back to yellow because the tldraw COLOR_MAP in NoteShape.tsx was missing those entries. Added violet, red, and light-blue mappings so the note shape renders with the correct background color. Added tests to verify the color strings survive element-to-shape coercion and that COLOR_MAP has the expected entries. * docs(readme): External Coding Agents section (bring your own AI team) (#1816) * docs(readme): External Coding Agents section (registry, consent onboarding, kanban + a2a work loop) The external-agent collaboration flow (access requests approved from the phone, scoped registry identities, board claim/PR/close loop, a2a coordination) had no README presence despite being live and proven. Docs-Reviewed: readme-only change describing the shipped flow documented in docs/design/external-agent-onboarding.md * docs(readme): reference only docs that exist on dev doc-gate verifies every mentioned path exists; the project-invite design doc lives on a branch, so the section links only the onboarding doc. Docs-Reviewed: readme-only change describing the shipped flow documented in docs/design/external-agent-onboarding.md * fix(hub): serialize chain appends so racing posts are not orphaned (#1817) next_chain_position read the chain head and put_chain_object inserted with INSERT OR IGNORE, so two concurrent appends for the same author computed the same seq and the loser was silently dropped from the chain index while its body stayed in hub_objects. A per-store asyncio lock now serializes the read-position-then-insert section in append_post and delete_post; the local node is the only writer of its own chain, so this closes the race. Regression test proves three concurrent appends land as seq 1,2,3. Docs-Reviewed: hardening of the merged design doc docs/design/hub-social-network-foundation.md slice 4, no doc change needed * fix(canvas): map text elements to visible taos-text shapes (#1819) * fix(canvas): map text elements to visible taos-text shapes element-to-shape.ts only mapped note/link/image to custom shape types; text (and mermaid label) fell through to taos-generic whose props only carry geometry, so the payload never reached a visible label. Add a taos-text shape util and map kind=text to it, coercing payload.text to a string with empty-string default so imperfect agent writes still render. Add tests for text kind mapping, payload coercion, and fallback behavior. * chore(canvas): doc-gate trailer for text shape New desktop source (TextShape.tsx) rendering the canvas text kind; no behavioral doc needed, the canvas element kinds are covered by the design. Docs-Reviewed: frontend-only canvas fix for the boarded canvas text render bug, no doc change needed * feat(projects): element overview grid, creation flow, and drill-in navigation (slice 3) (#1820) * feat(projects): element overview grid, creation flow, and drill-in navigation (slice 3) Implements slice 3 of docs/design/projects-nested-elements.md on the frontend: - New elements/ registry (types.ts) with the seven known types, their icons, and type-driven landing-tab order. - ElementGrid: overview grid shown when a project has elements, with a fixed Project card and an Add element tile; zero-element projects keep today's workspace pane untouched (back-compat invariant). - ElementCard: type icon, name, type label, open/total task counts, owner chip, and a recent-activity line. - ElementCreateDialog: create a single nested element (name, slug, type, optional owner) within an existing project. - CreateProjectDialog: optional second step to seed nested elements; skipping yields a project identical to today's. - ProjectWorkspace: element drill-in scopes the board to the element and lands on the type's preferred tab, with a breadcrumb and the element id carried on the URL for deep links. - ProjectBoard/BoardToolbar: accept a scoped element id and hide the element filter bar while scoped. Tests cover the grid, card, create dialog, the zero-element regression, drill-in with breadcrumb, and the creation-flow step two. * chore(projects): doc-gate trailer for elements slice 3 UI New desktop sources under ProjectsApp/elements/ implementing slice 3 of the merged nested-elements design; frontend-only, no behavioral doc change. Docs-Reviewed: implements the merged design doc docs/design/projects-nested-elements.md slice 3 * Add confirm guard before video delete (#1821) Docs-Reviewed: frontend change to the shipped Video Studio surface. * Projects elements slice 4: element-scoped canvas + files (#1822) Add an element_id tag to canvas items (store column + ALTER migration, element-filtered list and create on the canvas routes), an adopt-existing element files subfolder helper, untag-on-delete for canvas items, and wire the frontend so the canvas honors the active element filter and the Files tab mounts the element subfolder. Docs-Reviewed: implements the boarded task, frontend/backend change to shipped surface. * feat(projects): doc-review stamp store + routes (#1802 slice 3) Add per-document review_state machine (awaiting_review/approved/changes_requested) with actor recording and timestamps, project-scoped agent-token gated routes, and the desktop stamp badge column plus typed API client. Docs-Reviewed: doc-review stamp store + routes slice per document-review-surface.md * feat(projects): add doc-review stamp badge to FilesApp and fix projects.ts types - Add ReviewBadge component with onClick support for cycling review state - Add cycleDocReview callback for in-place state transitions - Wire badge into FileRow (list view) and grid cards (project: locations only) - Fetch review states on project-location navigation via projectsApi.docReviews.list - Remove duplicate DocReviewState/DocReview type definitions from projects.ts - Remove duplicate docReview API block; keep single canonical docReviews namespace - Use DocReview | DocReviewMissing union for GET response type - Apply encodeURIComponent to state filter query parameter Docs-Reviewed: doc-review stamp store + routes slice per document-review-surface.md --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * fix(installer): normalise tree ownership before re-run update (#1840) A re-run of install-server.sh over an existing checkout drops to the repo-owning user (the 'taos' service user) for the git fetch + reset, to avoid running git as root inside a user-writable tree. But it only reads the TOP-LEVEL dir owner. If a prior install was interrupted mid-chown (or a root step wrote a few paths back), the tree has MIXED ownership: the owning user then cannot unlink the still-root-owned paths, so the reset fails with 'unable to unlink old ...: Permission denied' -> 'Could not reset index file to revision origin/master', bricking every subsequent re-run. Normalise ownership to the owning user (chown -R, run by root) right before the update so the reset can rewrite the whole tree. Safe: root does the chown and git still runs unprivileged. Reported on #2 (fresh Orange Pi 5 Plus, retry after a partial first run). Failure class reproduced locally: an unwritable path in the tree yields the identical unlink-EACCES; normalising the tree makes the reset apply cleanly. * fix(projects): create element_id indexes after migration, not in SCHEMA (boot-brick) (#1853) The canvas and task stores put their element_id index in SCHEMA: CREATE INDEX ... ON project_canvas_elements(project_id, element_id) CREATE INDEX ... ON project_tasks(project_id, element_id) BaseStore runs SCHEMA (executescript) BEFORE _post_init, so on an existing pre-element_id database the index creation raised 'no such column: element_id' before the _post_init ALTER could add the column, crashing controller boot after an upgrade. Reproduced live: the Pi bricked on startup right after pulling this code (fresh installs were fine because SCHEMA creates the table WITH element_id, so CI never exercised the migration path). Move both indexes out of SCHEMA into _post_init, created after the ALTER. Same class of bug and fix as the registry active-handle index (#1841). Regression test seeds a pre-element_id DB and asserts both stores boot, migrate the column, and build the index. * fix(hailo): install from hailo_model_zoo_genai via cmake, not a nonexistent repo (#1851) The installer defaulted HAILO_OLLAMA_REPO to hailo-ai/hailo-ollama, which does not exist (git ls-remote: Repository not found), so every Hailo-10H install failed at clone. The Hailo-Ollama server is not a standalone repo: it ships inside hailo-ai/hailo_model_zoo_genai and is built from source there (its README: an Ollama-compatible API written in C++ on top of HailoRT). - Point the repo at hailo_model_zoo_genai, pinned to a real commit. - Replace the Python venv/pip build with the repo's actual cmake flow (configure -> build -> install), adding the C++ toolchain + OpenSSL deps. cmake --install lands the hailo-ollama binary in /usr/local/bin and manifests under /usr/local/share/hailo-ollama. - Resolve the binary from PATH (system install), not a venv. - Start the server bare (it has no serve subcommand or --port flag) and set the listen port via OLLAMA_HOST (bind 127.0.0.1 on the managed port 7836, off the banned default 8000), per the repo's docs/USAGE.rst. Not hardware-tested locally (no Hailo-10H here); validated by bash -n and the upstream build docs. doc62fr (#1771) tests live on real hardware and reports logs. * chore(deps): bump mcp in the uv group across 1 directory (#2010) Bumps the uv group with 1 update in the / directory: [mcp](https://github.com/modelcontextprotocol/python-sdk). Updates `mcp` from 1.27.2 to 1.28.1 - [Release notes](https://github.com/modelcontextprotocol/python-sdk/releases) - [Changelog](https://github.com/modelcontextprotocol/python-sdk/blob/main/RELEASE.md) - [Commits](https://github.com/modelcontextprotocol/python-sdk/compare/v1.27.2...v1.28.1) --- updated-dependencies: - dependency-name: mcp dependency-version: 1.28.1 dependency-type: indirect dependency-group: uv ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump immutable (#2090) Bumps the npm_and_yarn group with 1 update in the /desktop directory: [immutable](https://github.com/immutable-js/immutable-js). Updates `immutable` from 4.3.8 to 4.3.9 - [Release notes](https://github.com/immutable-js/immutable-js/releases) - [Changelog](https://github.com/immutable-js/immutable-js/blob/main/CHANGELOG.md) - [Commits](https://github.com/immutable-js/immutable-js/compare/v4.3.8...v4.3.9) --- updated-dependencies: - dependency-name: immutable dependency-version: 4.3.9 dependency-type: indirect dependency-group: npm_and_yarn ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * release: promote dev to master for v1.0.0-beta.44 (#2122) * 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. * 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. * 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. * 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). * 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. * 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. * feat(agents): request additional scopes for an existing agent identity (#1921) * feat(agents): request additional scopes for an existing agent identity Add a scope-request flow so an already-registered agent can gain more scope grants on its SAME canonical_id, instead of the auth-request flow which mints a new identity on approval (and 409s on an active-handle collision). Endpoints (in routes/agent_auth_requests.py): - POST /api/agents/registry/{cid}/scope-requests (create) - POST /api/agents/registry/{cid}/scope-requests/{id}/approve - POST /api/agents/registry/{cid}/scope-requests/{id}/deny Auth (security-critical): creation is gated to the agent's OWN registry bearer token (sub == canonical_id) OR the owning user / an admin, because the agent already holds credentials; an anonymous caller can never escalate an existing identity. The middleware allowlist exposes only the create path to a registry JWT; approve/deny are owner/admin only. Approval writes add_grant(cid, scope, project_id) per granted scope (idempotent via the UNIQUE key), never registers a second identity, and lets the admin narrow but not widen the requested scopes. decisions_read/decisions_write are grantable globally or per-project; project_tasks and canvas scopes still require an explicit project_id. Adds AgentScopeRequestsStore, a scope-agnostic check_agent_identity helper, and full route tests. VALID_SCOPES stays in sync with _ALLOWED_SCOPES. Fixes #1920 * fix(agents): fold scope-request approval security findings (#1921) Addresses the Kilo + CodeRabbit findings on the approve/create scope-request paths: - Major (project binding): approve_scope_request bound global-capable scopes (decisions_*) to effective_project, which fell back to the agent-named req.project_id when the operator gave no explicit project_id. Since an agent can self-request, that let a global scope bind to any project the operator never validated (cross-project escalation). Now grants bind ONLY to the operator's explicit body.project_id (None = global); the agent-named value is never a binding. - Atomicity + races: approve_scope_request wrote grants + membership before the set_decision flip with no lock, so concurrent approvals could double-grant. Wrapped the whole approval in the same per-request _get_approve_lock the consent path uses, with a pending re-check inside the lock (grant-before-flip is safe under the lock + idempotent add_grant). - Info leak: create_scope_request now authorizes BEFORE scope-vocabulary validation, so an unauthorized caller cannot probe whether a scope name is valid. Adds tests: global scope ignores an agent-supplied project_id (binds global), and create authorizes before vocab (403 not a 400 vocab leak). 16 tests pass. * fix(agents): take the per-request lock in deny_scope_request too (#1921) Kilo review: approve_scope_request now serializes concurrent approvals under _get_approve_lock; deny lacked the same lock, so a concurrent approve+deny of the same request was unserialized. Wrap the deny body in the same per-req lock with a pending re-check, matching approve and the sibling consent path. * fix(apps): register assistant-studio as an installable optional app (#2104) Assistant Studio (#2103) shipped in the frontend registry as optional but was not in the server-side optional-app catalog, so getLaunchableApps hid it (it is only shown once installed) and POST /api/apps/optional/assistant-studio/install returned 'not an optional app'. Add it to OPTIONAL_FRONTEND_APPS + the version / trust / provenance dicts, matching the other Creative Studios. * fix(shortcuts): resolve the container's real incus project (and start it) for terminal shortcuts (#2105) The agent terminal/TUI shortcuts opened an incus PTY with no --project flag, so incus used the client's default project (a per-user one like user-999). An agent whose container lives in a different project (e.g. a legacy container in 'default') failed with 'Failed to fetch instance taos-agent- in project user-999: Instance not found', even though the container exists. Every other container op already resolves the real project via _resolve_container_project (--all-projects); the PTY path was the lone exception. _open_incus_pty now resolves the container's actual project and passes --project, and starts the container if it is stopped (incus exec fails on a non-running instance), so a dev-access shortcut works regardless of project or run state. Adds a sync _resolve_project_and_state_sync sibling to the async resolver. Tests: exec targets the resolved project + stopped container is started; a running container is not restarted. * test(scope-requests): add grant-enforcement E2E, tighten approve tolerance, add deny negative test (#2108) Three test gaps filled following PR #1921 merge: 1. E2E grant-enforcement test (test_approved_scope_grant_unlocks_route_e2e): agent self-requests decisions_write → admin approves → agent uses token on POST /api/decisions → assert 200. Proves the full grant-enforcement chain is wired end to end. Regression guard for issue #2095. 2. Tighten test_agent_cannot_approve_its_own_request: replace (401, 403) tolerance with exact 401. The middleware does not pass a registry JWT through to the approve handler (only the create path is allowlisted), so it falls to the session gate → 401 exactly. 3. Deny negative test (test_agent_cannot_deny_its_own_request): same auth model as approve — middleware does not allowlist the deny endpoint for registry JWTs, so an agent token on the deny endpoint falls through to the session gate → 401. Co-authored-by: Hogne <227774406+hognek@users.noreply.github.com> * chore(deps): bump actions/setup-python from 6 to 7 (#2109) Bumps [actions/setup-python](https://github.com/actions/setup-python) from 6 to 7. - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/setup-python dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump the python-deps group with 2 updates (#2110) Updates the requirements on [matrix-nio](https://github.com/matrix-nio/matrix-nio) and [litellm[proxy]](https://github.com/BerriAI/litellm) to permit the latest version. Updates `matrix-nio` from 0.25.2 to 0.26.0 - [Changelog](https://github.com/matrix-nio/matrix-nio/blob/main/CHANGELOG.md) - [Commits](https://github.com/matrix-nio/matrix-nio/compare/0.25.2...0.26.0) Updates `litellm[proxy]` to 1.93.0 - [Release notes](https://github.com/BerriAI/litellm/releases) - [Commits](https://github.com/BerriAI/litellm/compare/v1.92.0...v1.93.0) --- updated-dependencies: - dependency-name: matrix-nio dependency-version: 0.26.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: python-deps - dependency-name: litellm[proxy] dependency-version: 1.93.0 dependency-type: direct:production dependency-group: python-deps ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(release): v1.0.0-beta.44 version bump + changelog (#2121) * chore(release): v1.0.0-beta.44 version bump + changelog * docs(changelog): complete the beta.44 entry (dialog/mint fix, task-create scope) * test(desktop): add unit tests for AssistantStudioApp (#2116) * test(desktop): add unit tests for AssistantStudioApp * test(desktop): await the mount-time agents fetch in the first two Assistant Studio tests Qodo review: the first two tests rendered the component without awaiting its mount-time /api/agents fetch, so the resulting setState could land outside React Testing Library's act() and flake in stricter environments. The later tests in the same file already waited, so this was an inconsistency as much as a latent flake. Both now drain the fetch before asserting. * fix(library): wire up the unused source ingest option (#2117) * fix(library): wire up the unused source ingest option * fix(library): remove the unused source ingest option instead of sending it Review found that serializing source only moved the silent drop server-side: /api/library/ingest accepts file, url and title only, and LibraryStore has no source column (its source_url is already derived from the url). So the field was discarded either way, while now looking wired. The card offered wire-it-in or remove-it and I recommended wire-it-in without checking the backend, which was wrong. Removing it is the honest fix: no caller passes source, so nothing breaks, and a dead option no longer implies a feature that does not exist. Capturing real source metadata is a backend feature, not a client nit. * chore(deps): bump the spa-deps group in /desktop with 17 updates (#2111) Bumps the spa-deps group in /desktop with 17 updates: | Package | From | To | | --- | --- | --- | | [@radix-ui/react-dialog](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/dialog) | `1.1.19` | `1.1.23` | | [@radix-ui/react-dropdown-menu](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/dropdown-menu) | `2.1.20` | `2.1.24` | | [@radix-ui/react-label](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/label) | `2.1.11` | `2.1.15` | | [@radix-ui/react-select](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/select) | `2.3.3` | `2.3.7` | | [@radix-ui/react-slot](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/slot) | `1.3.0` | `1.3.3` | | [@radix-ui/react-switch](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/switch) | `1.3.3` | `1.3.7` | | [@radix-ui/react-tabs](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/tabs) | `1.1.17` | `1.1.21` | | [@radix-ui/react-tooltip](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/tooltip) | `1.2.12` | `1.2.16` | | [@tiptap/extension-link](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-link) | `3.28.0` | `3.29.0` | | [@tiptap/extension-underline](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-underline) | `3.28.0` | `3.29.0` | | [@tiptap/pm](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/pm) | `3.28.0` | `3.29.0` | | [@tiptap/react](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/react) | `3.28.0` | `3.29.0` | | [@tiptap/starter-kit](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/starter-kit) | `3.28.0` | `3.29.0` | | [react](https://github.com/react/react/tree/HEAD/packages/react) | `19.2.7` | `19.2.8` | | [react-dom](https://github.com/react/react/tree/HEAD/packages/react-dom) | `19.2.7` | `19.2.8` | | [@playwright/test](https://github.com/microsoft/playwright) | `1.61.1` | `1.62.0` | | [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) | `6.0.3` | `6.0.4` | Updates `@radix-ui/react-dialog` from 1.1.19 to 1.1.23 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/dialog/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/dialog) Updates `@radix-ui/react-dropdown-menu` from 2.1.20 to 2.1.24 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/dropdown-menu/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/dropdown-menu) Updates `@radix-ui/react-label` from 2.1.11 to 2.1.15 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/label/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/label) Updates `@radix-ui/react-select` from 2.3.3 to 2.3.7 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/select/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/select) Updates `@radix-ui/react-slot` from 1.3.0 to 1.3.3 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/slot/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/slot) Updates `@radix-ui/react-switch` from 1.3.3 to 1.3.7 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/switch/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/switch) Updates `@radix-ui/react-tabs` from 1.1.17 to 1.1.21 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/tabs/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/tabs) Updates `@radix-ui/react-tooltip` from 1.2.12 to 1.2.16 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/tooltip/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/tooltip) Updates `@tiptap/extension-link` from 3.28.0 to 3.29.0 - [Release notes](https://github.com/ueberdosis/tiptap/releases) - [Changelog](https://github.com/ueberdosis/tiptap/blob/main/packages/extension-link/CHANGELOG.md) - [Commits](https://github.com/ueberdosis/tiptap/commits/v3.29.0/packages/extension-link) Updates `@tiptap/extension-underline` from 3.28.0 to 3.29.0 - [Release notes](https://github.com/ueberdosis/tiptap/releases) - [Changelog](https://github.com/ueberdosis/tiptap/blob/main/packages/extension-underline/CHANGELOG.md) - [Commits](https://github.com/ueberdosis/tiptap/commits/v3.29.0/packages/extension-underline) Updates `@tiptap/pm` from 3.28.0 to 3.29.0 - [Release notes](https://github.com/ueberdosis/tiptap/releases) - [Changelog](https://github.com/ueberdosis/tiptap/blob/main/packages/pm/CHANGELOG.md) - [Commits](https://github.com/ueberdosis/tiptap/commits/v3.29.0/packages/pm) Updates `@tiptap/react` from 3.28.0 to 3.29.0 - [Release notes](https://github.com/ueberdosis/tiptap/releases) - [Changelog](https://github.com/ueberdosis/tiptap/blob/main/packages/react/CHANGELOG.md) - [Commits](https://github.com/ueberdosis/tiptap/commits/v3.29.0/packages/react) Updates `@tiptap/starter-kit` from 3.28.0 to 3.29.0 - [Release notes](https://github.com/ueberdosis/tiptap/releases) - [Changelog](https://github.com/ueberdosis/tiptap/blob/main/packages/starter-kit/CHANGELOG.md) - [Commits](https://github.com/ueberdosis/tiptap/commits/v3.29.0/packages/starter-kit) Updates `react` from 19.2.7 to 19.2.8 - [Release notes](https://github.com/react/react/releases) - [Changelog](https://github.com/react/react/blob/main/CHANGELOG.md) - [Commits](https://github.com/react/react/commits/v19.2.8/packages/react) Updates `react-dom` from 19.2.7 to 19.2.8 - [Release notes](https://github.com/react/react/releases) - [Changelog](https://github.com/react/react/blob/main/CHANGELOG.md) - [Commits](https://github.com/react/react/commits/v19.2.8/packages/react-dom) Updates `@playwright/test` from 1.61.1 to 1.62.0 - [Release notes](https://github.com/microsoft/playwright/releases) - [Commits](https://github.com/microsoft/playwright/compare/v1.61.1...v1.62.0) Updates `@vitejs/plugin-react` from 6.0.3 to 6.0.4 - [Release notes](https://github.com/vitejs/vite-plugin-react/releases) - [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@6.0.4/packages/plugin-react) --- updated-dependencies: - dependency-name: "@radix-ui/react-dialog" dependency-version: 1.1.23 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: spa-deps - dependency-name: "@radix-ui/react-dropdown-menu" dependency-version: 2.1.24 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: spa-deps - dependency-name: "@radix-ui/react-label" dependency-version: 2.1.15 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: spa-deps - dependency-name: "@radix-ui/react-select" dependency-version: 2.3.7 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: spa-deps - dependency-name: "@radix-ui/react-slot" dependency-version: 1.3.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: spa-deps - dependency-name: "@radix-ui/react-switch" dependency-version: 1.3.7 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: spa-deps - dependency-name: "@radix-ui/react-tabs" dependency-version: 1.1.21 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: spa-deps - dependency-name: "@radix-ui/react-tooltip" dependency-version: 1.2.16 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: spa-deps - dependency-name: "@tiptap/extension-link" dependency-version: 3.29.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: spa-deps - dependency-name: "@tiptap/extension-underline" dependency-version: 3.29.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: spa-deps - dependency-name: "@tiptap/pm" dependency-version: 3.29.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: spa-deps - dependency-name: "@tiptap/react" dependency-version: 3.29.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: spa-deps - dependency-name: "@tiptap/starter-kit" dependency-version: 3.29.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: spa-deps - dependency-name: react dependency-version: 19.2.8 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: spa-deps - dependency-name: react-dom dependency-version: 19.2.8 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: spa-deps - dependency-name: "@playwright/test" dependency-version: 1.62.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: spa-deps - dependency-name: "@vitejs/plugin-react" dependency-version: 6.0.4 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: spa-deps ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * fix(agents): bind project_tasks_create and files scopes to a project on approval (#2127) * fix(agents): bind project_tasks_create and files scopes to a project on approval Review of the beta.44 promotion found that _SCOPE_PROJECT_SCOPES listed only project_tasks and the canvas scopes, so project_tasks_create, files_read and files_write could be approved with no project_id. The grant was then written global (project_id=None), and check_agent_scope_for_project only matches a grant bound to the project, so the operator believed they had granted access while the agent silently had none. Fails closed, but silently wrong is its own bug. Also replaces the em dashes in docs/agent-coordination.md with commas and colons per the house style, and records in project_files.py why the session path deliberately allows an unknown slug (lazily-created, slug-addressed files tree, documented by test_list_unknown_slug_returns_empty) while the agent path stays strict. 42 tests pass (project files, files agent scope, scope requests). * fix(projects): setting an agent as project lead now sets lead_member_id (#2113) add_agent_to_project wrote role='lead' on the member row but never called set_lead, which is the only writer of projects.lead_member_id. The member row is just a label; the pointer column is the actual lead. So the agent read as lead in the UI while every lead-gated check refused them. This is what happened to Hermes on taOSrabbit: role='lead', is_lead=0, lead_member_id NULL. Best-effort like the rest of that block, since the membership and grant already stand on their own. * fix(agents): one definition of which scopes require a project binding Qodo caught that the auth-request approval path still granted files_* and project_tasks_create globally. The project-scope set existed as three parallel copies (two function-local, one module-level), and the earlier fix only corrected the module-level one -- leaving the path an invite actually takes still writing those grants with project_id=None. check_agent_scope_for_project only matches a grant bound to that exact project, so a global grant never matches. The approval returns 200, the operator believes access was granted, and the agent silently has none. Now defined once and referenced everywhere; _SCOPE_* are plain aliases rather than rebuilt literals, since re-listing the members is how the copies drifted. Adds the regression test that was missing: nothing pinned this set, which is why three copies could disagree unnoticed. Checks alias identity (not equality) so a re-introduced copy fails even while it still happens to agree, asserts a single assignment per name in the module source, and asserts every project-bound scope is in VALID_SCOPES -- a typo there fails open, granting globally. * ci: raise the test timeout above the actual suite runtime (#2134) The cap was 45 min with a comment claiming 3.12/3.13 finish in ~16. Measured over the last 12 job records that is no longer true: 3.13 takes 31-41 min and 3.12 takes 38-41, so the cap sat roughly 4 minutes above the slowest normal run. Two of those 12 were killed mid-suite with nothing actually wrong, including the one gating #2127. A cap that close to the median does not catch hangs, it manufactures red PRs, and a timeout kill is indistinguishable from a real failure until you check the clock against timeout-minutes. That is the worst property a merge gate can have. 75 keeps a bound on a genuinely hung job while leaving real headroom. The suite growing from ~16 to ~40 min is its own problem and is filed separately; this stops it corrupting merge decisions in the meantime. * feat(feedback): add per-user 24h submission cap (#2131) * feat(feedback): add per-user 24h submission cap * fix(feedback): make the 24h cap atomic (Qodo) The cap did a SELECT COUNT then an INSERT as two awaited calls. Every sequential test passes and the limit still does not hold: concurrent requests all read the same count, all see room, and all insert. Verified against the pre-fix code, 8 racers with one slot left all got 201 and took a cap of 20 to 27. A user with a few tabs open trips this without trying, and a scripted client defeats it outright. Moved enforcement into the store as a single INSERT ... SELECT ... WHERE (SELECT COUNT(*) ...) < ?, so SQLite's write lock makes the check and the insert indivisible, and rowcount says which way it went. Adds the concurrency test that was missing, plus one asserting a rejected submission leaves no row behind: a partially-written reject would tighten the cap on every retry. * refactor(feedback): drop count_recent, orphaned by the atomic cap This PR added count_recent for the route to call before inserting. Moving the cap into create_within_cap left it with no callers anywhere in the tree, so it is dead on arrival rather than pre-existing code worth keeping. Removing it also removes the tempting wrong path: a future caller reaching for count_recent would reintroduce exactly the check-then-insert race the atomic statement exists to close. * test: replace always-true assert with issubclass check in test_installer_class_available (#1979) * fix(install): route LXC port allocation through centralized allocator Replace the standalone _find_free_port() in lxc_installer.py with allocate_host_port(app_id) from port_allocator.py so the centralized allocator is the single source of truth for all app host-port assignments. - Remove _find_free_port(), socket, and closing imports from lxc_installer - Import allocate_host_port instead of RESERVED_PORTS - Accumulate failed ports in exclude set across TOCTOU retry loop - Fix stale _docker_published_port docstring (DockerInstaller now maps {allocated_host_port}:{container_port}, not {p}:{p}) - Add test class verifying _find_free_port is gone and allocate_host_port is the only import Refs: #695 * test: strengthen allocator import assertion per Kilo suggestion Add assert not hasattr(mod, 'RESERVED_PORTS') to verify the old import is truly removed, not just that allocate_host_port is present. * test: replace always-true assert with issubclass check in test_installer_class_available --------- Co-authored-by: Hogne <227774406+hognek@users.noreply.github.com> * feat(wallpaper): add Wallhaven proxy route + sectioned picker integration (#1902) * feat(wallpaper): add Wallhaven proxy route + browse-online picker section - Add wallhaven_api_key config field (env-only, never in repo) - Create GET /api/wallhaven/search proxy route to wallhaven.cc API - Keyless by default; optional X-API-Key header when WALLHAVEN_API_KEY set - Handle rate limiting (429), timeouts (504), and Wallhaven errors (502) - New WallhavenBrowser component: debounced search, thumbnail grid, pagination - Integrate WallhavenBrowser into WallpaperPicker as collapsible section - WallpaperPicker: "Browse online" toggle expands search UI, selecting a Wallhaven image applies it as a remote wallpaper - Backend tests (test_wallhaven.py, 12 tests) with respx mocking - Frontend tests (WallhavenBrowser.test.tsx, 8 tests; WallpaperPicker 14 tests) Fixes #864 * fix(wallpaper): escape CSS url, persist wallpaperIdByTheme, guard JSONResponse, validate categories/purity, move os import - Escape single-quotes and backslashes in remote wallpaper URLs to prevent CSS injection and render breakage (WallpaperPicker.tsx onSelect). - Route through a state updater that sets wallpaperIdByTheme, and include light/mobile/fallback variants so theme switch preserves remote wallpapers. - Use the received label as wallpaperOverlayText instead of discarding it. - Guard resp.json() with try/except ValueError and cap response at 1 MB (wallhaven.py:71). - Validate categories/purity with ^[01]{3}$ before forwarding to Wallhaven. - Move import os as _os from inside load_config to module top (config.py). * fix(wallpaper): harden CSS url() escaping — escape parens, strip control chars, validate http(s) scheme Kilo WARNING: CSS url() escaping was incomplete. Only backslashes and single-quotes were escaped, allowing ')' in query strings to prematurely terminate url('...') and inject CSS. Now also: - Escape '(' as %28 and ')' as %29 - Strip control characters (\x00-\x1f, \x7f) - Validate scheme is http(s) before proceeding Also removes a duplicate 'Browse online' section left behind during the sectioned-picker rebase — changes are low-risk and build/vitest pass. --------- Co-authored-by: Hogne <227774406+hognek@users.noreply.github.com> * fix(gpu-arbiter): clamp drain_tick_seconds floor + remove double _signal_capacity wake (#1987) * feat(gpu-arbiter): event-driven admission wakeup replacing 2s poll tick (#1864 A3) Replace the hardcoded asyncio.sleep(2) in _process_queue with an asyncio.Event-based wake path. The drain loop now blocks on asyncio.wait_for(self._wake.wait(), timeout=drain_tick_seconds) so a reservation release or task completion immediately wakes the drain loop. The 2 s constant becomes the configurable fallback timeout (drain_tick_seconds, default 2.0). - __init__: new drain_tick_seconds param, self._wake Event - _signal_capacity(): new method — calls self._wake.set() - _process_queue: event-driven wait + clear, fallback timeout - _release_reservation: calls _signal_capacity on actual release - _run_gpu_task finally: calls _signal_capacity on completion - tests/test_gpu_arbiter_wakeup.py: 2 new tests * test_release_triggers_immediate_drain (60 s tick, < 0.5 s admit) * test_poll_tick_still_drains_without_signal (0.1 s tick) * fix(gpu-arbiter): clamp drain_tick_seconds floor + remove double _signal_capacity wake Kilo review on #1986: 1. Clamp drain_tick_seconds to >= 0.01 in __init__ to prevent asyncio.wait_for(timeout=0) ValueError crash in _process_queue. 2. Remove redundant _signal_capacity() in _run_gpu_task finally — _release_reservation already calls it at line 250, making the line-448 call a double-wake on every task completion. 32/32 GPU arbiter tests pass. --------- Co-authored-by: Hogne <227774406+hognek@users.noreply.github.com> * fix(peer): address Kilo WARNINGs — os.environ leak, design doc, rate limiter docs (#2032) * fix(peer): address Kilo WARNINGs — align design doc, FIXME rate limiter - Design doc: outbound_token comment now matches code (plaintext; deferred to post-MVP) instead of misleading 'encrypted at rest'. - Rate limiter: add explicit FIXME for shared-store backing across workers, document per-worker aggregate limit semantics. Note: monkeypatch.setenv fixture fix was already applied in upstream merge of #2025, so this commit carries only the two remaining warnings. * fix(peer): address 5 Kilo findings — centralized auth, nonce replay, rate-limit LRU, prune commit, token docs Fix #1 (WARNING): Document outbound_token plaintext threat model in contacts_store schema comment — token must be presented on outbound requests; at-rest encryption deferred to post-MVP. Fix #2 (WARNING): Centralized /api/peer/ authentication via router-level _peer_auth_dep dependency. Previously EXEMPT_PREFIXES bypassed all auth middleware and correctness depended on every route calling _authenticate_peer. Now every route under /api/peer/ gets bearer-auth automatically; route handlers read contact_id from request.state. Fix #3 (SUGGESTION): Commit the opportunistic nonce prune in its own transaction so a replay (IntegrityError) rollback does not undo it. Fix #4 (SUGGESTION): Add record_nonce(…, kind='ack') to /api/peer/ack for replay protection. A replayed ack now returns 409 Conflict, matching the /inbox and /chat contract. Fix #5 (SUGGESTION): Add LRU fallback eviction to the rate limiter. When all 2000+ entries have active windows (no expired entries to sweep), the oldest entry is evicted to prevent unbounded dict growth under sustained load. --------- Co-authored-by: Hogne <227774406+hognek@users.noreply.github.com> * fix(notifications): show approve/deny for scope-request notifications in bell (#2107) * fix(notifications): show approve/deny buttons for agent_scope_requests in bell and toast PR #1921 (agent scope-requests) emits notifications with source 'agent_scope_requests' but NotificationCentre and NotificationToast only rendered ConsentActions for source === 'auth_requests'. Scope- request approve/deny was invisible in the UI. - Branch source check in NotificationCentre and NotificationToast to also accept 'agent_scope_requests' - ConsentActions now accepts optional source + canonicalId props and routes approve/deny to the scope-request endpoints when source is 'agent_scope_requests' (/api/agents/registry/{id}/scope-requests/...) - consentPayload() extracts canonical_id from notification data for the scope-request endpoint path - Backward compatible: source defaults to 'auth_requests' for existing callers (DecisionsApp, tests) Ref: #1921 * fix: guard missing canonicalId for scope-request consent actions Per CodeRabbit review: the canonicalId ?? requestId fallback could produce malformed URLs like /registry/{requestId}/scope-requests/... when canonical_id is missing from the notification data. Now explicitly rejects scope-request approve/deny with a clear error when the canonicalId is absent. --------- Co-authored-by: Hogne <227774406+hognek@users.noreply.github.com> * fix(csrf): merge CSRF token into effectiveInit for Request inputs (bot-fix for #1991) (#1999) * fix(csrf): merge CSRF token into effectiveInit for Request inputs, not rebuild - Compute effective method from init?.method || input.method - Merge token into effectiveInit headers instead of rebuilding Request (avoids body stream consumption and respects init-provided headers/method) - Update test to inspect init.headers instead of Request.headers Addresses Kilo finding (Request inputs excluded from CSRF) and CodeRabbit finding (broken token injection when init overrides method/headers + body stream consumption). Refs: PR #1991 * fix(csrf): merge Request headers with init headers instead of choosing one Kilo's WARNING on this PR was correct. `init?.headers || input.headers` picks one source, so whenever a caller supplied headers via BOTH the Request and the init, the Request's own headers were discarded entirely: fetch(new Request(url, {headers: {Authorization}}), {method, headers}) lost the Authorization header. init also wins at the fetch layer for a Request plus init, so nothing downstream restored it. Now both are merged, init winning on conflict, which matches how fetch itself resolves the two. The regression test is verified to fail on the pre-fix code, and it exposes the bug as slightly worse than reported: with the old line, that call lost the CSRF token too, so the header this PR exists to attach was itself dropped in exactly the case it was being extended to cover. Test assertions live inside the existing it() block on purpose: installAuthGuard has a module-level `installed` flag, so a second install in a fresh it() is a no-op and window.fetch would be the bare spy rather than the wrapper. I lost time to that before spotting it, hence the note. --------- Co-authored-by: Hogne <227774406+hognek@users.noreply.github.com> Co-authored-by: jaylfc * fix(canvas): make the .tldr export a file tldraw can actually open (#2133) * fix(canvas): make the .tldr export a file tldraw can actually open The .tldr snapshot is the data-recovery escape hatch for #2132: it is what a user falls back on to get their canvas out and open it elsewhere. It did not work, and nothing tested that it did. Two independent faults, both verified against tldraw 4.5.12 rather than assumed: 1. Wrong container entirely. We emitted {schema, store{}}, the shape of an in-memory store snapshot. A .tldr FILE is {tldrawFileFormatVersion, schema, records[]}. tldraw's tldrawFileValidator requires all three, so parseTldrawJsonFile threw and returned notATldrawFile -- a flat refusal to open, before it ever looked at the content. The declared schemaVersion 2 also lacked the required sequences dict. 2. Shapes stock tldraw cannot render. note/link/image were typed taos-note / taos-link / taos-image, our own shape utils. Nobody else's tldraw has them. On the live Pi that is 55 of 64 live elements. The rest were geo shapes carrying taos_* keys in props, which tldraw rejects as unknown props. Fixed by emitting the real envelope with a serialized schema captured from tldraw, mapping every kind onto native note/text/geo shapes, and moving taOS provenance into meta, which tldraw carries through untouched. Deliberately does NOT pass through a user_shape's literal tldraw_shape blob, despite that being the highest-fidelity option. One invalid record makes tldraw reject the WHOLE file, so a single stale blob would cost the user their entire board in the one file whose only job is recovery. That risk is concrete here: we declare a fixed schema constant, so tldraw runs no migration on a blob written by an older version. The lossless copy belongs in the canonical JSON export instead. The raw blob stays untouched in the DB payload either way. Index keys are generated properly rather than hardcoded to "a1": tldraw validates them, so "a10" is rejected outright (a fraction may not end in 0), and reusing one key discards z-order. Also sets Content-Disposition, without which the browser renders the JSON inline and the user never gets a file. Verified on real data, not just fixtures: all three live Pi boards exported and loaded into a stock tldraw with every shape intact (22/22, 25/25, 17/17). * test(canvas): update the two tests that pinned the old .tldr shape Both asserted "store" in body, which encoded the bug this branch fixes: the in-memory {schema, store{}} store-snapshot shape rather than the {tldrawFileFormatVersion, schema, records[]} file envelope tldraw actually accepts. They now assert the file format and read shapes out of records. My miss: these live under tests/projects/ and I only ran the files I had touched, so CI found them instead of me. Swept the tree for any other consumer of the old shape and there are none. * fix(canvas): make every field read in the .tldr export non-fatal CodeRabbit and Qodo independently caught the same bug, and they were right. _element_text did payload.get(...), but the `or {}` guard only catches falsy payloads: a truthy non-dict (a list or bare string decoded from the DB) reached .get() and raised AttributeError. el["id"], el["x"], el["kind"] could equally raise KeyError on an absent column. Any of those aborts _build_tldraw_snapshot for the WHOLE project. This is the recovery export, so it runs precisely on the boards whose rows are already ragged, and the failure mode was that one odd row costs the user every other shape they were trying to rescue. That directly contradicts the rule this file already states, which makes it a bug in the code rather than in the intent. Every read now degrades to a safe default instead of raising. A string payload becomes the shape's label, since if that is all the row holds it is the most useful thing to show. _num_or excludes bool deliberately: bool is an int subclass, so True would otherwise sail through as a width of 1.0 and silently misplace a shape rather than fall back. Verified against tldraw's real parser, not just unit tests: the live Pi board with malformed rows spliced in exports 67 elements and loads all 67 shapes in a stock tldraw. --------- Signed-off-by: dependabot[bot] Co-authored-by: hognek Co-authored-by: Hogne <227774406+hognek@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * fix(spa): restore the docReviews client + types dropped in conflict resolution FilesApp's doc-review UI (master-only, retained) imports DocReviewState and calls projects.docReviews; the dev-side sweep of desktop/src/lib/projects.ts had dropped master's client block, so tsc failed. Ported the docReviews block and its three types; master's redundant docReview (singular) duplicate block was deliberately NOT ported. SPA builds clean locally. * ci: re-trigger deleted-symbols gate with the Removes-Intentionally waiver The gate reads github.event.pull_request.body from the event payload, so a workflow re-run replays the ORIGINAL body and never sees a waiver added by editing the PR afterwards. An empty commit raises a fresh synchronize event carrying the current body; the tree is unchanged, so the promote-tree-identity assertion (diff vs dev empty) still holds. * fix(theme-store): use injected get() to break circular self-reference Replace the useThemeStore.getState() call inside the create closure with the get() second argument of zustand's create. Eliminates the TS7022 / TS7006 cascade caused by referencing the store within its own initializer. Only the in-closure occurrence changes; module-level getState() calls (outside the creator) keep referencing the store hook directly. * chore: retrigger CLA check --------- Signed-off-by: dependabot[bot] Co-authored-by: jaylfc Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Hogne <227774406+hognek@users.noreply.github.com> --- desktop/src/stores/theme-store.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/desktop/src/stores/theme-store.ts b/desktop/src/stores/theme-store.ts index e2bf16240..16129d4a6 100644 --- a/desktop/src/stores/theme-store.ts +++ b/desktop/src/stores/theme-store.ts @@ -226,7 +226,7 @@ interface ThemeStore { getWallpapersBySection: () => WallpaperSection[]; } -export const useThemeStore = create((set) => ({ +export const useThemeStore = create((set, get) => ({ wallpaperId: DEFAULT_WP.id, wallpaperImage: DEFAULT_WP.image, wallpaperMobileImage: DEFAULT_WP.mobileImage ?? DEFAULT_WP.image, @@ -303,7 +303,7 @@ export const useThemeStore = create((set) => ({ getWallpapers: () => WALLPAPERS, getWallpapersBySection: () => { - const state = useThemeStore.getState(); + const state = get(); // The theme's declared default wallpaper id, or the global fallback. const themeDefaultId = state.themeDefaultWallpaperId[state.activeThemeId] || "graphite"; From 527278bf637b7606e91d8325bb25302f75d0b810 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Sun, 9 Aug 2026 16:54:23 +0000 Subject: [PATCH 03/56] test: add xfail-strict acceptance tests for generic LXC service install Pins the behavior a non-Gitea LXC manifest (service_name, ports, ui_port, ui_path, state_paths) must get once the generic install path lands: systemd unit named from service_name, no dl.gitea.com binary download, no /etc/gitea/app.ini, declared ports published, state_paths created in-container, and admin_password not required when no admin user is declared. Each test is xfail(strict=True, reason=...) so they stay green today by failing as expected, and become XPASS failures the instant the implementation ships -- forcing the marker to be removed. --- tests/test_lxc_installer.py | 168 ++++++++++++++++++++++++++++++++++++ 1 file changed, 168 insertions(+) diff --git a/tests/test_lxc_installer.py b/tests/test_lxc_installer.py index 19108a01a..17c0bce6b 100644 --- a/tests/test_lxc_installer.py +++ b/tests/test_lxc_installer.py @@ -24,6 +24,17 @@ } +GENERIC_INSTALL_CONFIG = { + "method": "lxc", + "image": "images:debian/bookworm", + "service_name": "taosr1", + "ports": [8080], + "ui_port": 8080, + "ui_path": "/", + "state_paths": ["/var/lib/taosr1"], +} + + # --------------------------------------------------------------------------- # LXCInstaller unit tests # --------------------------------------------------------------------------- @@ -639,3 +650,160 @@ async def test_lxc_uninstall_container_error_blocks_store_removal(self, lxc_clie data = resp.json() assert "container_error" in data assert "container gone" in data["container_error"] + + +# --------------------------------------------------------------------------- +# Generic (non-Gitea) LXC service install acceptance tests +# --------------------------------------------------------------------------- + + +class TestLXCInstallerGenericService: + """Acceptance tests for a generic (non-Gitea) LXC service install. + + The installer is today hard-wired to Gitea: it writes gitea.service, + downloads a binary from dl.gitea.com, renders /etc/gitea/app.ini and + unconditionally requires admin_password. A manifest that declares only + image, service_name, ports, ui_port, ui_path and state_paths should not be + forced through that Gitea path. Each test pins one piece of the behaviour + the generic path MUST provide once it lands. They are xfail(strict=True): + green today by failing as expected, and the moment the implementation + ships they XPASS, which strict mode flips into a failure that forces the + marker to be removed. + """ + + async def _run_generic_install( + self, + captured_cmds: list, + *, + admin_password: str = "secret", + host_port: int = 13000, + ) -> tuple[dict, AsyncMock]: + installer = LXCInstaller() + proxy_mock = AsyncMock(return_value={"success": True}) + + async def fake_exec(container_name, cmd, timeout=300): + captured_cmds.append(cmd) + if "hostname" in cmd or "-I" in cmd: + return (0, "10.0.0.2") + return (0, "") + + with ( + patch( + "tinyagentos.installers.lxc_installer.containers.container_exists", + new_callable=AsyncMock, + return_value=False, + ), + patch( + "tinyagentos.installers.lxc_installer.containers.create_container", + new_callable=AsyncMock, + return_value={"success": True}, + ), + patch( + "tinyagentos.installers.lxc_installer.containers.exec_in_container", + side_effect=fake_exec, + ), + patch( + "tinyagentos.installers.lxc_installer.containers.add_proxy_device", + proxy_mock, + ), + patch( + "tinyagentos.installers.lxc_installer.allocate_host_port", + return_value=host_port, + ), + ): + result = await installer.install( + "taosr1", + GENERIC_INSTALL_CONFIG, + admin_password=admin_password, + taos_username="jay", + taos_email="jay@example.com", + ) + return result, proxy_mock + + @pytest.mark.asyncio + @pytest.mark.xfail( + strict=True, + reason="generic LXC install path not implemented; owned by @hermes", + ) + async def test_systemd_unit_named_from_service_name(self): + captured: list[list[str]] = [] + await self._run_generic_install(captured) + full_cmds = [" ".join(c) for c in captured] + assert any( + "/etc/systemd/system/taosr1.service" in cmd for cmd in full_cmds + ), "systemd unit must be named from service_name (taosr1.service)" + assert not any( + "/etc/systemd/system/gitea.service" in cmd for cmd in full_cmds + ), "must not write the Gitea-specific gitea.service unit" + + @pytest.mark.asyncio + @pytest.mark.xfail( + strict=True, + reason="generic LXC install path not implemented; owned by @hermes", + ) + async def test_no_gitea_binary_download(self): + captured: list[list[str]] = [] + await self._run_generic_install(captured) + full_cmds = [" ".join(c) for c in captured] + assert not any( + "dl.gitea.com" in cmd for cmd in full_cmds + ), "must not make any request to dl.gitea.com" + assert not any( + "wget" in cmd and "gitea" in cmd.lower() + for cmd in full_cmds + ), "must not issue a Gitea binary download command" + + @pytest.mark.asyncio + @pytest.mark.xfail( + strict=True, + reason="generic LXC install path not implemented; owned by @hermes", + ) + async def test_no_app_ini_written(self): + captured: list[list[str]] = [] + await self._run_generic_install(captured) + full_cmds = [" ".join(c) for c in captured] + assert not any( + "app.ini" in cmd for cmd in full_cmds + ), "must not write /etc/gitea/app.ini for a generic service" + + @pytest.mark.asyncio + @pytest.mark.xfail( + strict=True, + reason="generic LXC install path not implemented; owned by @hermes", + ) + async def test_declared_ports_are_published(self): + captured: list[list[str]] = [] + _, proxy_mock = await self._run_generic_install(captured) + assert proxy_mock.call_count == 1, ( + "add_proxy_device should be called once for the declared port" + ) + connect = proxy_mock.call_args.kwargs.get("connect") + assert connect == "tcp:127.0.0.1:8080", ( + f"proxy device must publish the declared port 8080 (ui_port), " + f"got connect={connect!r}" + ) + + @pytest.mark.asyncio + @pytest.mark.xfail( + strict=True, + reason="generic LXC install path not implemented; owned by @hermes", + ) + async def test_state_paths_created_in_container(self): + captured: list[list[str]] = [] + await self._run_generic_install(captured) + full_cmds = [" ".join(c) for c in captured] + assert any( + "/var/lib/taosr1" in cmd for cmd in full_cmds + ), "state_paths must be created inside the container (/var/lib/taosr1)" + + @pytest.mark.asyncio + @pytest.mark.xfail( + strict=True, + reason="generic LXC install path not implemented; owned by @hermes", + ) + async def test_admin_password_not_required_without_admin_user(self): + captured: list[list[str]] = [] + result, _ = await self._run_generic_install(captured, admin_password="") + assert result["success"] is True, ( + "generic manifest with no admin user should not require admin_password" + ) From a0d68c39a7b1c33e5830789f07cc4f5cddfee6f6 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Mon, 27 Jul 2026 22:14:27 +0000 Subject: [PATCH 04/56] tsk-glxi4e [OPEN] Make 3-strike card QUARANTINE loud (it currently v --- tinyagentos/projects/strike_store.py | 98 ++++++++++++++++++++++++++++ tinyagentos/projects/task_store.py | 79 ++++++++++++++++++++++ 2 files changed, 177 insertions(+) create mode 100644 tinyagentos/projects/strike_store.py diff --git a/tinyagentos/projects/strike_store.py b/tinyagentos/projects/strike_store.py new file mode 100644 index 000000000..125a5c1e7 --- /dev/null +++ b/tinyagentos/projects/strike_store.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +import time + +from tinyagentos.base_store import BaseStore +from tinyagentos.projects.ids import new_id + +STRIKE_SCHEMA = """ +CREATE TABLE IF NOT EXISTS task_strikes ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + step TEXT NOT NULL, + log_tail TEXT NOT NULL DEFAULT '', + actor TEXT NOT NULL DEFAULT '', + created_at REAL NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_strikes_task ON task_strikes(task_id); +""" + + +def _row(r) -> dict: + keys = ("id", "task_id", "step", "log_tail", "actor", "created_at") + return dict(zip(keys, r)) + + +class StrikeStore(BaseStore): + """Append-only log of verification strikes per task. + + The dispatch host (taOS-dev) records a strike each time a card fails + verification. When the count reaches ``STRIKE_THRESHOLD`` the task is + quarantined (see ``ProjectTaskStore.quarantine_task``) and the lead is + notified. Strikes are auto-cleared when the task closes or its PR + merges (see ``ProjectTaskStore.close_task`` and the unquarantine route). + """ + + SCHEMA = STRIKE_SCHEMA + + # Number of failed verifications before a card is quarantined. + STRIKE_THRESHOLD = 3 + + async def record_strike( + self, + task_id: str, + step: str, + log_tail: str = "", + actor: str = "", + ) -> int: + """Record one verification strike for *task_id*. + + Returns the total strike count for the task after this insert (so the + caller can decide whether the threshold was just crossed). + """ + sid = new_id("str") + now = time.time() + await self._db.execute( + """INSERT INTO task_strikes + (id, task_id, step, log_tail, actor, created_at) + VALUES (?, ?, ?, ?, ?, ?)""", + (sid, task_id, step, log_tail, actor, now), + ) + await self._db.commit() + return await self.count_strikes(task_id) + + async def count_strikes(self, task_id: str) -> int: + async with self._db.execute( + "SELECT COUNT(*) FROM task_strikes WHERE task_id = ?", (task_id,) + ) as cur: + row = await cur.fetchone() + return row[0] if row else 0 + + async def list_strikes(self, task_id: str) -> list[dict]: + """All strikes for *task_id*, oldest first.""" + async with self._db.execute( + "SELECT * FROM task_strikes WHERE task_id = ? ORDER BY created_at ASC", + (task_id,), + ) as cur: + rows = await cur.fetchall() + return [_row(r) for r in rows] + + async def latest(self, task_id: str) -> dict | None: + """The most recent strike for *task_id*, or None.""" + async with self._db.execute( + "SELECT * FROM task_strikes WHERE task_id = ? ORDER BY created_at DESC LIMIT 1", + (task_id,), + ) as cur: + row = await cur.fetchone() + return _row(row) if row else None + + async def clear_strikes(self, task_id: str) -> int: + """Delete every strike for *task_id*. + + Returns the number of rows deleted (0 when there were none). + """ + cursor = await self._db.execute( + "DELETE FROM task_strikes WHERE task_id = ?", (task_id,) + ) + await self._db.commit() + return cursor.rowcount diff --git a/tinyagentos/projects/task_store.py b/tinyagentos/projects/task_store.py index 3233bc2dc..fd039fcd9 100644 --- a/tinyagentos/projects/task_store.py +++ b/tinyagentos/projects/task_store.py @@ -13,6 +13,7 @@ from tinyagentos.board_audit import BoardAuditLog from tinyagentos.projects.events import ProjectEventBroker from tinyagentos.projects.project_store import ProjectStore + from tinyagentos.projects.strike_store import StrikeStore logger = logging.getLogger(__name__) @@ -110,11 +111,13 @@ def __init__( broker: "ProjectEventBroker | None" = None, audit: "BoardAuditLog | None" = None, project_store: "ProjectStore | None" = None, + strikes: "StrikeStore | None" = None, ) -> None: super().__init__(db_path) self._broker = broker self._audit = audit self._project_store = project_store + self._strikes = strikes async def _publish(self, project_id: str, kind: str, payload: dict) -> None: if self._broker is not None: @@ -375,6 +378,15 @@ async def close_task( existing = await self.get_task(task_id) if existing is not None: await self._publish(existing["project_id"], "task.closed", {"id": task_id, "closed_by": closed_by}) + # A closed card can never be re-landed, so any lingering strikes + # are stale garbage that would make the dispatch lanes redo landed + # work. Clear them automatically (best-effort; the close itself + # has already committed). + if self._strikes is not None: + try: + await self._strikes.clear_strikes(task_id) + except Exception: + logger.warning("clear_strikes failed for task %s on close", task_id, exc_info=True) # Derive the pre-close status race-free from the committed row rather # than a separate pre-read (which would have a TOCTOU gap). close does # not clear claimed_by, so a set claimer means it was 'claimed'. @@ -409,6 +421,73 @@ async def reopen_task(self, task_id: str, reopened_by: str) -> bool: ) return changed + async def quarantine_task(self, task_id: str, actor: str) -> bool: + """Move a task into the ``quarantined`` status. + + A quarantined card is visible on the board (distinct column) but is + removed from the ready pool -- the fleet will not pick it up until a + lead explicitly un-quarantines it. Only acts on a task that is not + already closed/cancelled; returns False otherwise. + """ + now = time.time() + cursor = await self._db.execute( + """UPDATE project_tasks + SET status = 'quarantined', updated_at = ? + WHERE id = ? AND status NOT IN ('closed', 'cancelled', 'quarantined')""", + (now, task_id), + ) + await self._db.commit() + changed = cursor.rowcount == 1 + if changed: + existing = await self.get_task(task_id) + if existing is not None: + await self._publish( + existing["project_id"], + "task.quarantined", + {"id": task_id, "actor": actor}, + ) + await self._record_audit( + task_id, "task.quarantined", actor, "open", "quarantined", + project_id=existing["project_id"] if existing else "", + ) + return changed + + async def unquarantine_task(self, task_id: str, actor: str) -> bool: + """Return a quarantined task to the open pool and clear its strikes. + + This is the explicit un-quarantine / retry action: the card re-enters + the ready pool so the fleet may pick it up again. Strikes are cleared + so a fresh failure count starts from zero. Only acts on a quarantined + task; returns False otherwise. + """ + now = time.time() + cursor = await self._db.execute( + """UPDATE project_tasks + SET status = 'open', updated_at = ? + WHERE id = ? AND status = 'quarantined'""", + (now, task_id), + ) + await self._db.commit() + changed = cursor.rowcount == 1 + if changed: + if self._strikes is not None: + try: + await self._strikes.clear_strikes(task_id) + except Exception: + logger.warning("clear_strikes failed for task %s on unquarantine", task_id, exc_info=True) + existing = await self.get_task(task_id) + if existing is not None: + await self._publish( + existing["project_id"], + "task.unquarantined", + {"id": task_id, "actor": actor}, + ) + await self._record_audit( + task_id, "task.unquarantined", actor, "quarantined", "open", + project_id=existing["project_id"] if existing else "", + ) + return changed + async def add_relationship( self, project_id: str, From 3c71a7e9d034cc9618686c9184737f50f2cd9629 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Sun, 9 Aug 2026 18:25:26 +0000 Subject: [PATCH 05/56] Add store-wiring-gate CI check for unwired BaseStore subclasses - scripts/check_store_wiring.py detects PRs that add a new BaseStore subclass without wiring it into tinyagentos/app.py - Uses name-level check (class name appears in app.py) - Only flags newly added classes; pre-existing orphans are skipped - Store-Unwired-Intentionally: , trailer waives and logs - .github/workflows/store-wiring-gate.yml runs the check on PRs - tests/test_check_store_wiring.py proves FAIL, PASS, existing orphan not flagged, trailer waiver, and transitive subclass detection --- .github/workflows/store-wiring-gate.yml | 43 ++ changelog.d/tsk-n3w5mh-store-wiring-gate.md | 11 + scripts/check_store_wiring.py | 273 +++++++++++++ tests/test_check_store_wiring.py | 415 ++++++++++++++++++++ 4 files changed, 742 insertions(+) create mode 100644 .github/workflows/store-wiring-gate.yml create mode 100644 changelog.d/tsk-n3w5mh-store-wiring-gate.md create mode 100644 scripts/check_store_wiring.py create mode 100644 tests/test_check_store_wiring.py diff --git a/.github/workflows/store-wiring-gate.yml b/.github/workflows/store-wiring-gate.yml new file mode 100644 index 000000000..e8f30aa9d --- /dev/null +++ b/.github/workflows/store-wiring-gate.yml @@ -0,0 +1,43 @@ +name: Store wiring gate + +# Detects PRs that add a new BaseStore subclass without wiring it into +# tinyagentos/app.py. Routes reach stores ONLY via request.app.state, so a +# store that is never assigned to app.state is unreachable. +# +# Start with a NAME-LEVEL check (class name appears in the lifespan file). +# Only newly added classes are policed; pre-existing orphans are skipped so +# the check is mergeable on any branch. +# +# A "Store-Unwired-Intentionally: , " trailer in the PR body +# waives a named class and logs it, for stores genuinely constructed elsewhere +# (tests, CLI, workers). +# +# See scripts/check_store_wiring.py for the implementation. + +on: + pull_request: + branches: [master, dev] + +jobs: + store-wiring-gate: + runs-on: ubuntu-latest + permissions: + contents: read + env: + BASE_REF: ${{ github.base_ref }} + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - uses: actions/setup-python@v7 + with: + python-version: "3.12" + + - name: Fetch base branch + run: git fetch origin "$BASE_REF" + + - name: Check for unwired BaseStore subclasses + env: + PR_BODY: ${{ github.event.pull_request.body }} + run: python scripts/check_store_wiring.py --base "origin/$BASE_REF" diff --git a/changelog.d/tsk-n3w5mh-store-wiring-gate.md b/changelog.d/tsk-n3w5mh-store-wiring-gate.md new file mode 100644 index 000000000..b0bf0a7cc --- /dev/null +++ b/changelog.d/tsk-n3w5mh-store-wiring-gate.md @@ -0,0 +1,11 @@ +### Added + +- **CI**: store-wiring-gate workflow and `scripts/check_store_wiring.py` guard. + A PR that adds a new BaseStore subclass without wiring it into + `tinyagentos/app.py` now fails CI and names the unreachable class and file. + Routes reach stores ONLY via `request.app.state`, so an unwired store is + dead code. Start with a NAME-LEVEL check (class name appears in the lifespan + file). Only newly added classes are policed; pre-existing orphans are not + flagged. A `Store-Unwired-Intentionally: , ` trailer in the + PR body waives a named class and logs it, for stores genuinely constructed + elsewhere (tests, CLI, workers). diff --git a/scripts/check_store_wiring.py b/scripts/check_store_wiring.py new file mode 100644 index 000000000..c6d4cdc24 --- /dev/null +++ b/scripts/check_store_wiring.py @@ -0,0 +1,273 @@ +#!/usr/bin/env python3 +"""BaseStore wiring guard. + +Detects PRs that add a new BaseStore subclass but never wire it into +tinyagentos/app.py. Routes reach stores ONLY via request.app.state, so a +store that is never assigned to app.state is unreachable. + +Algorithm: + 1. Scan the PR diff for Python files under tinyagentos/. + 2. For each newly added file, find classes that subclass BaseStore + (directly or transitively). + 3. For each modified file, find classes whose ``class Foo(BaseStore)`` + definition line appears in the added diff lines. + 4. For each newly-added store class, check that its class name appears + somewhere in tinyagentos/app.py (name-level check). + 5. A "Store-Unwired-Intentionally: , " trailer in the PR + body waives a named class and logs it. + +Usage: + python scripts/check_store_wiring.py + python scripts/check_store_wiring.py --base origin/dev + python scripts/check_store_wiring.py --base origin/dev --pr-body "..." +""" +from __future__ import annotations + +import argparse +import ast +import os +import re +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +TRAILER = "Store-Unwired-Intentionally:" + + +@dataclass +class Violation: + class_name: str + file_path: str + + +def _run_git(args: list[str], repo_root: Path) -> str: + result = subprocess.run( + ["git", *args], cwd=repo_root, capture_output=True, text=True, check=True, + ) + return result.stdout + + +def _parse_name_status(output: str) -> list[tuple[str, str]]: + changed: list[tuple[str, str]] = [] + for line in output.splitlines(): + if not line.strip(): + continue + parts = line.split("\t") + status = parts[0] + path = parts[-1] + changed.append((status[0], path)) + return changed + + +def _git_changed(base_ref: str, repo_root: Path) -> list[tuple[str, str]]: + out = _run_git(["diff", "--name-status", f"{base_ref}...HEAD"], repo_root) + return _parse_name_status(out) + + +def _get_file_at_ref(file_path: str, ref: str, repo_root: Path) -> str | None: + try: + result = subprocess.run( + ["git", "show", f"{ref}:{file_path}"], + cwd=repo_root, capture_output=True, text=True, check=True, + ) + return result.stdout + except subprocess.CalledProcessError: + return None + + +def _class_def_in_added_lines( + file_path: str, class_name: str, base_ref: str, repo_root: Path, +) -> bool: + """Return True if the class definition is newly added in the PR.""" + base_content = _get_file_at_ref(file_path, base_ref, repo_root) + if base_content is not None: + try: + base_tree = ast.parse(base_content) + for node in ast.walk(base_tree): + if isinstance(node, ast.ClassDef) and node.name == class_name: + return False + except SyntaxError: + pass + + diff = _run_git(["diff", f"{base_ref}...HEAD", "--", file_path], repo_root) + pattern = re.compile(rf"^\+.*class\s+{re.escape(class_name)}\s*\(", re.MULTILINE) + return bool(pattern.search(diff)) + + +def build_class_hierarchy(repo_root: Path) -> dict[str, set[str]]: + """Build a map of class_name -> set of direct base class names.""" + classes: dict[str, set[str]] = {} + tinyagentos_dir = repo_root / "tinyagentos" + if not tinyagentos_dir.is_dir(): + return classes + for py_file in sorted(tinyagentos_dir.rglob("*.py")): + try: + source = py_file.read_text(encoding="utf-8", errors="ignore") + tree = ast.parse(source) + except (OSError, SyntaxError): + continue + for node in ast.walk(tree): + if isinstance(node, ast.ClassDef): + bases = set() + for base in node.bases: + if isinstance(base, ast.Name): + bases.add(base.id) + classes[node.name] = bases + return classes + + +def _inherits_base_store( + class_name: str, + classes: dict[str, set[str]], + visited: set[str] | None = None, +) -> bool: + if visited is None: + visited = set() + if class_name == "BaseStore": + return True + if class_name in visited: + return False + visited.add(class_name) + for base in classes.get(class_name, set()): + if _inherits_base_store(base, classes, visited): + return True + return False + + +def find_base_store_subclasses_in_file( + source: str, all_classes: dict[str, set[str]], +) -> set[str]: + try: + tree = ast.parse(source) + except SyntaxError: + return set() + + classes_in_file: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.ClassDef): + classes_in_file.add(node.name) + + return { + name for name in classes_in_file + if _inherits_base_store(name, all_classes) + } + + +def parse_waived_classes(pr_body: str | None) -> set[str]: + """Parse Store-Unwired-Intentionally trailer from PR body text. + + Expected format: ``Store-Unwired-Intentionally: , `` + Only the first comma-delimited token is treated as the class name; the + remainder is the human-readable reason. + """ + waived: set[str] = set() + if not pr_body: + return waived + for line in pr_body.splitlines(): + line = line.strip() + if line.startswith(TRAILER): + classes_str = line[len(TRAILER):].strip() + cls = classes_str.split(",", 1)[0].strip() + if cls: + waived.add(cls) + return waived + + +def check_store_wiring( + base_ref: str, + repo_root: Path = REPO_ROOT, + pr_body: str | None = None, +) -> tuple[list[Violation], set[str]]: + changed = _git_changed(base_ref, repo_root) + + app_py_path = repo_root / "tinyagentos" / "app.py" + app_py_content = "" + if app_py_path.exists(): + app_py_content = app_py_path.read_text(encoding="utf-8", errors="ignore") + + all_classes = build_class_hierarchy(repo_root) + + violations: list[Violation] = [] + waived: set[str] = set() + waived.update(parse_waived_classes(pr_body)) + + for status, file_path in changed: + if not file_path.startswith("tinyagentos/") or not file_path.endswith(".py"): + continue + if status.startswith("D"): + continue + if not (status.startswith("A") or status.startswith("M")): + continue + + abs_path = repo_root / file_path + if not abs_path.exists(): + continue + + source = abs_path.read_text(encoding="utf-8", errors="ignore") + store_classes = find_base_store_subclasses_in_file(source, all_classes) + if not store_classes: + continue + + for class_name in sorted(store_classes): + is_new = False + if status.startswith("A"): + is_new = True + elif status.startswith("M"): + is_new = _class_def_in_added_lines( + file_path, class_name, base_ref, repo_root, + ) + + if not is_new: + continue + + if class_name in waived: + waived.add(class_name) + continue + + if not re.search(rf"\b{re.escape(class_name)}\b", app_py_content): + violations.append(Violation( + class_name=class_name, + file_path=file_path, + )) + + return violations, waived + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--base", default=None, help="Target branch ref (e.g. origin/dev)") + parser.add_argument("--pr-body", default=None, help="PR body text (for Store-Unwired-Intentionally trailer)") + args = parser.parse_args(argv) + + base_ref = args.base + if base_ref is None: + base_ref = os.environ.get("BASE_REF", "origin/dev") + + pr_body = args.pr_body + if pr_body is None: + pr_body = os.environ.get("PR_BODY") + + violations, waived_classes = check_store_wiring(base_ref, REPO_ROOT, pr_body) + + if waived_classes: + for cls in sorted(waived_classes): + print(f"store-wiring-guard: waived via Store-Unwired-Intentionally: {cls}") + + if violations: + print( + f"STORE-WIRING FAIL: {len(violations)} new BaseStore subclass(es) are not wired " + f"into tinyagentos/app.py. Routes reach stores ONLY via request.app.state, " + f"so an unwired store is unreachable:" + ) + for v in violations: + print(f" - {v.class_name} in {v.file_path}") + return 1 + + print("store-wiring-guard: clean") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_check_store_wiring.py b/tests/test_check_store_wiring.py new file mode 100644 index 000000000..dcb681ce4 --- /dev/null +++ b/tests/test_check_store_wiring.py @@ -0,0 +1,415 @@ +"""Tests for the BaseStore wiring guard (scripts/check_store_wiring.py). + +Each integration test builds a synthetic git repo in a temp directory, +merges a PR branch into base to produce the merge result, checks out the +merge commit, and then calls check_store_wiring() directly against the +pre-merge base tip. This proves the check goes RED (fails), GREEN (passes), +and that the Store-Unwired-Intentionally trailer waives a named class. +""" +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent / "scripts")) +import check_store_wiring as csw # noqa: E402 + +REPO_ROOT = Path(__file__).resolve().parent.parent + + +def _git(repo: Path, *args: str) -> None: + subprocess.run(["git", *args], cwd=repo, capture_output=True, text=True, check=True) + + +def _init_repo(repo: Path) -> None: + repo.mkdir(parents=True, exist_ok=True) + _git(repo, "init") + _git(repo, "config", "user.name", "Test") + _git(repo, "config", "user.email", "test@test.com") + _git(repo, "config", "commit.gpgsign", "false") + _git(repo, "branch", "-M", "main") + + +def _write(repo: Path, rel_path: str, content: str) -> None: + full = repo / rel_path + full.parent.mkdir(parents=True, exist_ok=True) + full.write_text(content, encoding="utf-8") + + +def _commit(repo: Path, rel_path: str, content: str, message: str) -> None: + _write(repo, rel_path, content) + _git(repo, "add", rel_path) + _git(repo, "commit", "-m", message) + + +def _branch(repo: Path, name: str) -> None: + _git(repo, "branch", name) + + +def _checkout(repo: Path, name: str) -> None: + _git(repo, "checkout", "-q", name) + + +def _get_head(repo: Path) -> str: + result = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=repo, capture_output=True, text=True, check=True, + ) + return result.stdout.strip() + + +def _setup_base_repo(repo: Path) -> str: + _init_repo(repo) + _commit(repo, "tinyagentos/__init__.py", "", "init: package root") + _commit( + repo, "tinyagentos/base_store.py", + "class BaseStore:\n SCHEMA = ''\n MIGRATIONS = []\n", + "feat: add BaseStore", + ) + _commit( + repo, "tinyagentos/metrics_store.py", + "from tinyagentos.base_store import BaseStore\n\n" + "class MetricsStore(BaseStore):\n" + " SCHEMA = 'CREATE TABLE IF NOT EXISTS metrics (id INTEGER PRIMARY KEY);'\n" + " MIGRATIONS = []\n", + "feat: add MetricsStore", + ) + _commit( + repo, "tinyagentos/app.py", + "from tinyagentos.base_store import BaseStore\n" + "from tinyagentos.metrics_store import MetricsStore\n\n" + "metrics_store = MetricsStore('/tmp/metrics.db')\n\n" + "async def lifespan(app):\n" + " await metrics_store.init()\n" + " app.state.metrics = metrics_store\n", + "feat: wire MetricsStore in app", + ) + return _get_head(repo) + + +# --------------------------------------------------------------------------- +# Core logic unit tests (no git required) +# --------------------------------------------------------------------------- + + +class TestFindBaseStoreSubclasses: + def test_direct_subclass(self): + source = ( + "from tinyagentos.base_store import BaseStore\n" + "\n" + "class Foo(BaseStore):\n" + " pass\n" + ) + all_classes = {"BaseStore": set(), "Foo": {"BaseStore"}} + result = csw.find_base_store_subclasses_in_file(source, all_classes) + assert result == {"Foo"} + + def test_transitive_subclass(self): + source = ( + "from tinyagentos.base_store import BaseStore\n" + "\n" + "class Parent(BaseStore):\n" + " pass\n" + "\n" + "class Child(Parent):\n" + " pass\n" + ) + all_classes = { + "BaseStore": set(), + "Parent": {"BaseStore"}, + "Child": {"Parent"}, + } + result = csw.find_base_store_subclasses_in_file(source, all_classes) + assert result == {"Parent", "Child"} + + def test_non_subclass_ignored(self): + source = "class NotAStore:\n pass\n" + all_classes = {"BaseStore": set(), "NotAStore": set()} + result = csw.find_base_store_subclasses_in_file(source, all_classes) + assert result == set() + + def test_syntax_error_returns_empty(self): + result = csw.find_base_store_subclasses_in_file("def (:\n", {"BaseStore": set()}) + assert result == set() + + +class TestParseWaivedClasses: + def test_parses_single_class(self): + body = "Store-Unwired-Intentionally: StrikeStore, test fixture" + assert csw.parse_waived_classes(body) == {"StrikeStore"} + + def test_no_trailer_returns_empty(self): + assert csw.parse_waived_classes("just a description") == set() + + def test_none_body_returns_empty(self): + assert csw.parse_waived_classes(None) == set() + + def test_trailer_with_reason(self): + body = "Store-Unwired-Intentionally: Foo, test-only fixture" + assert csw.parse_waived_classes(body) == {"Foo"} + + def test_multiple_trailer_lines(self): + body = "Store-Unwired-Intentionally: Foo\n\nStore-Unwired-Intentionally: Bar" + assert csw.parse_waived_classes(body) == {"Foo", "Bar"} + + +class TestClassDefInAddedLines: + def test_new_class_in_new_file(self, tmp_path: Path): + repo = tmp_path / "repo" + _init_repo(repo) + _commit(repo, "tinyagentos/app.py", "x = 1\n", "init") + base_tip = _get_head(repo) + _branch(repo, "pr") + _checkout(repo, "pr") + _commit( + repo, "tinyagentos/strike_store.py", + "from tinyagentos.base_store import BaseStore\n" + "\n" + "class StrikeStore(BaseStore):\n" + " pass\n", + "feat: add StrikeStore", + ) + _checkout(repo, "main") + _git(repo, "merge", "pr", "--no-edit") + + assert csw._class_def_in_added_lines( + "tinyagentos/strike_store.py", "StrikeStore", base_tip, repo, + ) + + def test_existing_class_not_flagged_as_new(self, tmp_path: Path): + repo = tmp_path / "repo" + _init_repo(repo) + _commit( + repo, "tinyagentos/orphan_store.py", + "from tinyagentos.base_store import BaseStore\n" + "\n" + "class OrphanStore(BaseStore):\n" + " pass\n", + "feat: add OrphanStore", + ) + base_tip = _get_head(repo) + _branch(repo, "pr") + _checkout(repo, "pr") + _commit( + repo, "tinyagentos/orphan_store.py", + "from tinyagentos.base_store import BaseStore\n" + "\n" + "class OrphanStore(BaseStore):\n" + " pass\n" + "\n" + " async def new_method(self):\n" + " pass\n", + "refactor: add method to OrphanStore", + ) + _checkout(repo, "main") + _git(repo, "merge", "pr", "--no-edit") + + assert not csw._class_def_in_added_lines( + "tinyagentos/orphan_store.py", "OrphanStore", base_tip, repo, + ) + + +# --------------------------------------------------------------------------- +# Integration tests with synthetic git repos (merge-result model) +# --------------------------------------------------------------------------- + + +class TestCheckStoreWiring: + def test_new_unwired_store_fails(self, tmp_path: Path): + repo = tmp_path / "repo" + base_tip = _setup_base_repo(repo) + + _branch(repo, "pr") + _checkout(repo, "pr") + _commit( + repo, "tinyagentos/strike_store.py", + "from tinyagentos.base_store import BaseStore\n" + "\n" + "class StrikeStore(BaseStore):\n" + " SCHEMA = 'CREATE TABLE IF NOT EXISTS strikes (id INTEGER PRIMARY KEY);'\n" + " MIGRATIONS = []\n", + "feat: add StrikeStore (#2172)", + ) + _checkout(repo, "main") + _git(repo, "merge", "pr", "--no-edit") + + violations, waived = csw.check_store_wiring(base_tip, repo) + + assert len(violations) == 1 + v = violations[0] + assert v.class_name == "StrikeStore" + assert v.file_path == "tinyagentos/strike_store.py" + assert waived == set() + + def test_new_wired_store_passes(self, tmp_path: Path): + repo = tmp_path / "repo" + base_tip = _setup_base_repo(repo) + + _branch(repo, "pr") + _checkout(repo, "pr") + _commit( + repo, "tinyagentos/wired_store.py", + "from tinyagentos.base_store import BaseStore\n" + "\n" + "class WiredStore(BaseStore):\n" + " SCHEMA = 'CREATE TABLE IF NOT EXISTS wired (id INTEGER PRIMARY KEY);'\n" + " MIGRATIONS = []\n", + "feat: add WiredStore", + ) + _commit( + repo, "tinyagentos/app.py", + "from tinyagentos.base_store import BaseStore\n" + "from tinyagentos.metrics_store import MetricsStore\n" + "from tinyagentos.wired_store import WiredStore\n\n" + "metrics_store = MetricsStore('/tmp/metrics.db')\n" + "wired_store = WiredStore('/tmp/wired.db')\n\n" + "async def lifespan(app):\n" + " await metrics_store.init()\n" + " await wired_store.init()\n" + " app.state.metrics = metrics_store\n" + " app.state.wired_store = wired_store\n", + "feat: wire WiredStore in app", + ) + _checkout(repo, "main") + _git(repo, "merge", "pr", "--no-edit") + + violations, waived = csw.check_store_wiring(base_tip, repo) + + assert violations == [] + assert waived == set() + + def test_existing_unwired_store_not_flagged(self, tmp_path: Path): + repo = tmp_path / "repo" + _init_repo(repo) + _commit(repo, "tinyagentos/__init__.py", "", "init: package root") + _commit( + repo, "tinyagentos/base_store.py", + "class BaseStore:\n SCHEMA = ''\n MIGRATIONS = []\n", + "feat: add BaseStore", + ) + _commit( + repo, "tinyagentos/orphan_store.py", + "from tinyagentos.base_store import BaseStore\n" + "\n" + "class OrphanStore(BaseStore):\n" + " SCHEMA = 'CREATE TABLE IF NOT EXISTS orphans (id INTEGER PRIMARY KEY);'\n" + " MIGRATIONS = []\n", + "feat: add OrphanStore (unwired)", + ) + _commit( + repo, "tinyagentos/app.py", + "from tinyagentos.base_store import BaseStore\n" + "\n" + "async def lifespan(app):\n" + " pass\n", + "feat: minimal app", + ) + base_tip = _get_head(repo) + + _branch(repo, "pr") + _checkout(repo, "pr") + _commit( + repo, "tinyagentos/orphan_store.py", + "from tinyagentos.base_store import BaseStore\n" + "\n" + "class OrphanStore(BaseStore):\n" + " SCHEMA = 'CREATE TABLE IF NOT EXISTS orphans (id INTEGER PRIMARY KEY);'\n" + " MIGRATIONS = []\n" + "\n" + " async def new_method(self):\n" + " pass\n", + "refactor: add method to existing OrphanStore", + ) + _checkout(repo, "main") + _git(repo, "merge", "pr", "--no-edit") + + violations, waived = csw.check_store_wiring(base_tip, repo) + + assert violations == [] + assert waived == set() + + def test_unwired_intentionally_trailer_waives(self, tmp_path: Path): + repo = tmp_path / "repo" + base_tip = _setup_base_repo(repo) + + _branch(repo, "pr") + _checkout(repo, "pr") + _commit( + repo, "tinyagentos/unwired_store.py", + "from tinyagentos.base_store import BaseStore\n" + "\n" + "class UnwiredStore(BaseStore):\n" + " SCHEMA = 'CREATE TABLE IF NOT EXISTS unwired (id INTEGER PRIMARY KEY);'\n" + " MIGRATIONS = []\n", + "feat: add UnwiredStore (test-only)", + ) + _checkout(repo, "main") + _git(repo, "merge", "pr", "--no-edit") + + pr_body = "Store-Unwired-Intentionally: UnwiredStore, used only in CLI tests" + violations, waived = csw.check_store_wiring(base_tip, repo, pr_body=pr_body) + + assert violations == [] + assert waived == {"UnwiredStore"} + + def test_transitive_subclass_flagged(self, tmp_path: Path): + repo = tmp_path / "repo" + base_tip = _setup_base_repo(repo) + + _branch(repo, "pr") + _checkout(repo, "pr") + _commit( + repo, "tinyagentos/child_store.py", + "from tinyagentos.base_store import BaseStore\n" + "\n" + "class ParentStore(BaseStore):\n" + " SCHEMA = 'CREATE TABLE IF NOT EXISTS parents (id INTEGER PRIMARY KEY);'\n" + " MIGRATIONS = []\n" + "\n" + "class ChildStore(ParentStore):\n" + " SCHEMA = 'CREATE TABLE IF NOT EXISTS children (id INTEGER PRIMARY KEY);'\n" + " MIGRATIONS = []\n", + "feat: add ParentStore and ChildStore", + ) + _checkout(repo, "main") + _git(repo, "merge", "pr", "--no-edit") + + violations, waived = csw.check_store_wiring(base_tip, repo) + + assert len(violations) == 2 + names = {v.class_name for v in violations} + assert names == {"ParentStore", "ChildStore"} + assert waived == set() + + def test_new_class_added_to_existing_file_fails(self, tmp_path: Path): + repo = tmp_path / "repo" + base_tip = _setup_base_repo(repo) + + _branch(repo, "pr") + _checkout(repo, "pr") + _commit( + repo, "tinyagentos/metrics_store.py", + "from tinyagentos.base_store import BaseStore\n" + "\n" + "class MetricsStore(BaseStore):\n" + " SCHEMA = 'CREATE TABLE IF NOT EXISTS metrics (id INTEGER PRIMARY KEY);'\n" + " MIGRATIONS = []\n" + "\n" + "\n" + "class NewStore(BaseStore):\n" + " SCHEMA = 'CREATE TABLE IF NOT EXISTS new_store (id INTEGER PRIMARY KEY);'\n" + " MIGRATIONS = []\n", + "feat: add NewStore alongside MetricsStore", + ) + _checkout(repo, "main") + _git(repo, "merge", "pr", "--no-edit") + + violations, waived = csw.check_store_wiring(base_tip, repo) + + assert len(violations) == 1 + assert violations[0].class_name == "NewStore" + assert violations[0].file_path == "tinyagentos/metrics_store.py" From d50646f8a2581ce2355fb1c1c455a6f00d182c87 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Sun, 9 Aug 2026 22:30:19 +0000 Subject: [PATCH 06/56] fix(agent-loop): safe-point race, timeout-safe awaits, task_utils reuse, honest docs - hold SAFE_POINT state across cancel window, queue arriving messages, remove destructive _message_queue.clear() in handle_message - use asyncio.wait instead of asyncio.wait_for in await_subagent and await_all_subagents so a timeout never cancels the subagent task - reuse _create_supervised_task for progress and runner tasks, cancel_and_wait for bounded cancellation, prune settled subagents and delivered entries, switch UI timestamps to time.time() - update changelog to describe library as not-yet-wired infrastructure, add not-yet-integrated banner to design doc --- changelog.d/tsk-rl2lfb-agent-loop.md | 6 + docs/design/agent-loop-subagents.md | 101 +++++ tests/test_agent_loop.py | 555 +++++++++++++++++++++++++++ tinyagentos/agent_loop.py | 487 +++++++++++++++++++++++ 4 files changed, 1149 insertions(+) create mode 100644 changelog.d/tsk-rl2lfb-agent-loop.md create mode 100644 docs/design/agent-loop-subagents.md create mode 100644 tests/test_agent_loop.py create mode 100644 tinyagentos/agent_loop.py diff --git a/changelog.d/tsk-rl2lfb-agent-loop.md b/changelog.d/tsk-rl2lfb-agent-loop.md new file mode 100644 index 000000000..bc8115a2f --- /dev/null +++ b/changelog.d/tsk-rl2lfb-agent-loop.md @@ -0,0 +1,6 @@ +### Added + +- **Agent loop infrastructure**: new `tinyagentos.agent_loop.AgentLoop` library + for subagent delegation and safe-point message queuing. This is standalone + infrastructure, not yet wired into the taOS chat agent or routes + (#tsk-rl2lfb). diff --git a/docs/design/agent-loop-subagents.md b/docs/design/agent-loop-subagents.md new file mode 100644 index 000000000..9e6948a0e --- /dev/null +++ b/docs/design/agent-loop-subagents.md @@ -0,0 +1,101 @@ +# Agent loop: subagents for heavy work + safe-point message queue + +## Context + +The main taOS chat agent (driven over opencode or ACP) processes one user turn +at a time. A turn is **atomic**: it spans prompt, tool calls, and the agent's +full reply. Interrupting a turn mid-tool-call corrupts state (partial edits, +half-written files, dangling subprocesses). + +Meanwhile, some user requests kick off heavy, long-running work (codebase +refactors, large file operations, multi-step builds). Blocking the main loop on +that work means the user cannot interrupt or redirect until it finishes, and +the agent cannot surface intermediate results. + +## Design + +The `AgentLoop` class (in `tinyagentos/agent_loop.py`) wraps the main agent's +turn loop with two mechanisms: + +### 1. Subagent delegation + +`spawn_subagent(task, worker)` runs an async `worker(progress_cb)` as a +supervised background task. The main loop stays responsive -- it can accept +messages, stream subagent progress via the `sink` callback, and surface +results -- while the subagent works concurrently. + +Subagents are tracked in `self._subagents` keyed by id. Each handle records +state (`running` / `completed` / `cancelled` / `failed`), result, and error. + +### 2. Safe-point message queue + +`handle_message(content, is_redirect=False)` is the single entry point for +incoming user messages: + +- **IDLE** -> the message starts a new turn immediately; the loop transitions + to `WORKING`. Returns `LoopAction.IMMEDIATE`. +- **WORKING** -> the message is appended to the queue; it is **never dropped** + and **never applied mid-step**. Returns `LoopAction.QUEUED`. + +`reach_safe_point()` is called at the end of a turn (the atomic boundary). It: + +1. Transitions `WORKING` -> `SAFE_POINT`. +2. Drains the message queue and returns the messages for the caller to act on. +3. If any queued message is a redirect, calls `cancel_subagents()` so in-flight + subagent work is aborted **at the safe boundary**, never mid-step. +4. Transitions back to `IDLE`. + +### 3. Cancel / redirect propagation + +`cancel_subagents(reason=None)` cancels every running subagent task and awaits +their unwind under a bounded timeout (mirrors `task_utils.cancel_and_wait`). +A redirect message -- queued during `WORKING` -- triggers this automatically +when `reach_safe_point` processes it. + +### 4. Visibility + +`status()` returns a snapshot dict with the current loop state, the current +turn id, running subagents (id / task / state / result / error), and the +queued message backlog (id / content / received_at / is_redirect). This lets +the UI show "agent is working" plus "N messages queued". + +## State machine + +``` +text +IDLE + |-- handle_message -> WORKING + | |-- spawn_subagent (concurrent) + | |-- handle_message -> QUEUED (buffered) + | |-- reach_safe_point -> SAFE_POINT + | |-- redirect? -> cancel_subagents + | |-- drain queue -> IDLE +SAFE_POINT --(immediate)--> IDLE +``` + +## Integration points + +> **Not yet integrated**: `AgentLoop` is standalone library infrastructure. +> It is not yet wired into the taOS chat agent routes. Routing integration +> will be handled in a separate design card once the `AgentChatRouter`-lock +> design decision is made. + +- The taOS chat endpoint (`routes/taos_agent.py`) can use `AgentLoop` to wrap + `opencode_runtime.drive_turn`: start a turn, spawn a subagent for long tool + calls, and drain the queue at the end of the stream. +- The `AgentChatRouter._run_acp_turn` path can delegate to a subagent via + `openclaw_acp_runtime.drive_turn` when a turn is expected to be long. + +## Testing + +`tests/test_agent_loop.py` covers: + +- **queue-not-drop**: a mid-task message is queued and returned at the safe + point (never lost). +- **safe-point-delivery**: a queued message is not acted on while the loop is + `WORKING`; it is surfaced only at `reach_safe_point`. +- **cancel-propagation**: a redirect at the safe point cancels an in-flight + subagent; `cancel_subagents` directly cancels running tasks. +- **visibility**: `status()` reports running subagents and the queue backlog. +- **subagent lifecycle**: completion, progress streaming, failure, and + completion-with-running-subagent. diff --git a/tests/test_agent_loop.py b/tests/test_agent_loop.py new file mode 100644 index 000000000..cd3bb6a56 --- /dev/null +++ b/tests/test_agent_loop.py @@ -0,0 +1,555 @@ +"""Tests for the agent loop: subagent delegation + safe-point message queue. + +Covers the three acceptance-test categories from the task: + - queue-not-drop: a message sent mid-task is queued, never lost. + - safe-point-delivery: a queued message is delivered at the safe boundary, + never applied mid-step. + - cancel-propagation: a redirect cancels in-flight subagent work. + +Plus visibility (what-is-running / what-is-queued) and the subagent lifecycle. +""" +from __future__ import annotations + +import asyncio + +import pytest + +from tinyagentos.agent_loop import ( + AgentLoop, + LoopAction, + LoopState, + QueuedMessage, + SubagentHandle, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +class _RecordingSink: + """Collects progress dicts the subagent streams back via the sink.""" + + def __init__(self): + self.received: list[dict] = [] + + def __call__(self, msg: dict): + self.received.append(msg) + + +async def _sleepy_worker(started: asyncio.Event, cancelled: asyncio.Event): + """A subagent worker that runs until cancelled, signalling both events.""" + + started.set() + try: + await asyncio.sleep(100) + except asyncio.CancelledError: + cancelled.set() + raise + + +async def _slow_cancel_worker(started: asyncio.Event, cancelled: asyncio.Event): + """A subagent worker that delays after receiving cancellation.""" + + started.set() + try: + await asyncio.sleep(100) + except asyncio.CancelledError: + await asyncio.sleep(0.2) + cancelled.set() + raise + + +async def _quick_worker(result="done"): + """A subagent worker that completes immediately with *result*.""" + + return result + + +# --------------------------------------------------------------------------- +# handle_message: IMMEDIATE vs QUEUED +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_handle_message_when_idle_returns_immediate_and_goes_working(): + """A message arriving while IDLE starts a turn immediately.""" + loop = AgentLoop() + assert loop.state == LoopState.IDLE + + action = await loop.handle_message("hello") + + assert action == LoopAction.IMMEDIATE + assert loop.state == LoopState.WORKING + + +@pytest.mark.asyncio +async def test_handle_message_when_working_is_queued_not_dropped(): + """A message arriving while WORKING is queued, never dropped.""" + loop = AgentLoop() + + await loop.handle_message("first turn") # IDLE -> WORKING + assert loop.state == LoopState.WORKING + + action = await loop.handle_message("mid-task message") + assert action == LoopAction.QUEUED + + # The message is buffered, visible via status and the message_queue property. + assert len(loop.message_queue) == 1 + assert loop.message_queue[0].content == "mid-task message" + assert loop.state == LoopState.WORKING # still working, not delivered + + +@pytest.mark.asyncio +async def test_multiple_messages_queued_in_arrival_order(): + """Several messages arriving during one turn are queued in FIFO order.""" + loop = AgentLoop() + await loop.handle_message("turn") + + for i, text in enumerate(("m1", "m2", "m3")): + action = await loop.handle_message(text, msg_id=f"msg-{i}") + assert action == LoopAction.QUEUED + + contents = [m.content for m in loop.message_queue] + assert contents == ["m1", "m2", "m3"] + + +# --------------------------------------------------------------------------- +# queue-not-drop: the queued message survives and is returned at the safe point +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_queued_message_survives_safe_point_and_is_returned(): + """A queued message is not dropped when the turn ends. + + After reaching the safe point the message is returned to the caller for + surfacing, and the delivered list is updated. + """ + loop = AgentLoop() + await loop.handle_message("long task") + + # Mid-task message is buffered. + await loop.handle_message("help", msg_id="mid-1") + assert len(loop.message_queue) == 1 + + delivered = await loop.reach_safe_point() + + # The message was returned (not dropped). + assert len(delivered) == 1 + assert delivered[0].content == "help" + assert delivered[0].id == "mid-1" + + # Loop is idle again and the delivered log reflects the surface. + assert loop.state == LoopState.IDLE + assert loop.delivered[-1].content == "help" + assert len(loop.message_queue) == 0 + + +@pytest.mark.asyncio +async def test_empty_queue_at_safe_point_returns_empty_list(): + """A safe point with no queued messages returns an empty list.""" + loop = AgentLoop() + await loop.handle_message("work") + + delivered = await loop.reach_safe_point() + + assert delivered == [] + assert loop.state == LoopState.IDLE + + +# --------------------------------------------------------------------------- +# safe-point-delivery: the message is NOT applied mid-step +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_queued_message_is_not_applied_mid_step(): + """A message queued during WORKING is not acted on until the safe point. + + We model "applied" as: the message content would change the turn's + behaviour. By asserting the loop is still WORKING and the queue is + non-empty before reach_safe_point, and that reach_safe_point is the + only path that yields the message, we prove it is not applied mid-step. + """ + loop = AgentLoop() + await loop.handle_message("original task") + assert loop.state == LoopState.WORKING + + await loop.handle_message("redirect: do something else", is_redirect=True) + + # While working, the redirect sits in the queue -- it has NOT been + # applied: the loop is still in the same turn. + assert loop.state == LoopState.WORKING + assert len(loop.message_queue) == 1 + assert loop.current_turn_id != "redirect: do something else" + + surfaced = await loop.reach_safe_point() + + # The redirect is delivered AT the safe point, not mid-step. + assert len(surfaced) == 1 + assert surfaced[0].content == "redirect: do something else" + assert surfaced[0].is_redirect is True + assert loop.state == LoopState.IDLE + + +@pytest.mark.asyncio +async def test_safe_point_only_after_all_work_completes(): + """reaching_safe_point while a subagent runs still delivers the queue. + + The safe point is a turn boundary: messages are surfaced regardless of + whether a subagent has finished, so the caller can decide to cancel. + """ + loop = AgentLoop() + await loop.handle_message("task") + + started = asyncio.Event() + cancelled_flag = asyncio.Event() + sub_id = await loop.spawn_subagent("heavy work", lambda p: _sleepy_worker(started, cancelled_flag)) + await started.wait() + assert loop.get_subagent(sub_id).state == "running" + + await loop.handle_message("queued msg") + assert len(loop.message_queue) == 1 + + delivered = await loop.reach_safe_point() + + # The message is surfaced at the safe boundary. + assert len(delivered) == 1 + assert delivered[0].content == "queued msg" + + # The subagent is still running (not cancelled -- no redirect this time). + assert loop.get_subagent(sub_id).state == "running" + + # Clean up the lingering subagent. + await loop.cancel_subagents() + assert loop.get_subagent(sub_id).state == "cancelled" + + +# --------------------------------------------------------------------------- +# cancel-propagation: redirect cancels in-flight subagent work +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_cancel_subagents_cancels_running_task(): + """cancel_subagents cancels a running subagent and awaits its unwind.""" + loop = AgentLoop() + started = asyncio.Event() + cancelled_flag = asyncio.Event() + + sub_id = await loop.spawn_subagent( + "heavy work", lambda p: _sleepy_worker(started, cancelled_flag), + ) + await started.wait() + + assert loop.get_subagent(sub_id).state == "running" + + count = await loop.cancel_subagents(reason="manual cancel") + assert count == 1 + + # The CancelledError propagated into the worker. + assert cancelled_flag.is_set() + + # The handle reflects the cancellation. + handle = loop.get_subagent(sub_id) + assert handle is not None + assert handle.state == "cancelled" + + +@pytest.mark.asyncio +async def test_redirect_at_safe_point_cancels_inflight_subagent(): + """A redirect queued during WORKING cancels subagents at the safe boundary. + + This is the full cancel-propagation flow: spawn -> redirect queued -> + reach_safe_point -> subagent cancelled -> redirect returned for re-dispatch. + """ + loop = AgentLoop() + await loop.handle_message("start a long build") + + started = asyncio.Event() + cancelled_flag = asyncio.Event() + + sub_id = await loop.spawn_subagent( + "building artifacts", lambda p: _sleepy_worker(started, cancelled_flag), + ) + await started.wait() + assert loop.get_subagent(sub_id).state == "running" + + # User sends a redirect while the subagent is busy. + action = await loop.handle_message("abort and restart", is_redirect=True) + assert action == LoopAction.QUEUED + + # The redirect is NOT applied mid-step. + assert loop.state == LoopState.WORKING + assert loop.has_pending_redirect() is True + + # Turn ends -> safe point. The redirect triggers subagent cancellation. + delivered = await loop.reach_safe_point() + await loop.await_subagent(sub_id) # ensure task fully settled + + # Subagent was cancelled by the redirect. + assert cancelled_flag.is_set() + assert loop.get_subagent(sub_id).state == "cancelled" + + # The redirect is returned so the caller can start a new turn. + assert len(delivered) == 1 + assert delivered[0].content == "abort and restart" + assert delivered[0].is_redirect is True + assert loop.state == LoopState.IDLE + + +@pytest.mark.asyncio +async def test_cancel_subagents_when_none_running_is_noop(): + """Cancelling with no in-flight subagents is a safe no-op.""" + loop = AgentLoop() + count = await loop.cancel_subagents() + assert count == 0 + assert loop.state == LoopState.IDLE + + +@pytest.mark.asyncio +async def test_cancel_subagents_only_cancels_running_not_completed(): + """Only running subagents are cancelled; completed ones are left alone.""" + loop = AgentLoop() + + sub_id = await loop.spawn_subagent("quick", lambda p: _quick_worker("ok")) + await loop.await_subagent(sub_id) + assert loop.get_subagent(sub_id).state == "completed" + + count = await loop.cancel_subagents() + assert count == 0 # nothing was running + + +# --------------------------------------------------------------------------- +# Subagent lifecycle & progress streaming +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_subagent_completes_and_result_is_accessible(): + """A successful subagent populates its handle with the result.""" + loop = AgentLoop() + + sub_id = await loop.spawn_subagent("compute", lambda p: _quick_worker(42)) + result = await loop.await_subagent(sub_id) + + assert result == 42 + handle = loop.get_subagent(sub_id) + assert handle.state == "completed" + assert handle.result == 42 + + +@pytest.mark.asyncio +async def test_subagent_progress_streamed_to_sink(): + """Progress dicts from the worker reach the loop's sink.""" + sink = _RecordingSink() + loop = AgentLoop(sink=sink) + + async def worker(progress): + progress({"kind": "reasoning", "content": "thinking..."}) + await asyncio.sleep(0) + progress({"kind": "delta", "content": "partial"}) + return "done" + + sub_id = await loop.spawn_subagent("streamy task", worker) + result = await loop.await_subagent(sub_id) + + assert result == "done" + assert len(sink.received) == 2 + assert sink.received[0]["kind"] == "reasoning" + assert sink.received[1]["kind"] == "delta" + + +@pytest.mark.asyncio +async def test_subagent_failure_sets_state_to_failed(): + """An exception in the worker sets state to 'failed' and records the error.""" + loop = AgentLoop() + + async def worker(progress): + raise RuntimeError("kaboom") + + sub_id = await loop.spawn_subagent("doomed", worker) + await loop.await_subagent(sub_id) + + handle = loop.get_subagent(sub_id) + assert handle.state == "failed" + assert "kaboom" in (handle.error or "") + + +# --------------------------------------------------------------------------- +# Visibility: status() -- what is running / what is queued +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_status_reflects_state_subagents_and_queue(): + """status() surfaces loop state, running subagents, and queued messages.""" + loop = AgentLoop() + await loop.handle_message("turn") + + started = asyncio.Event() + cancelled_flag = asyncio.Event() + sub_id = await loop.spawn_subagent( + "background scan", lambda p: _sleepy_worker(started, cancelled_flag), + ) + await started.wait() + + await loop.handle_message("queued user msg") + + st = loop.status() + assert st["state"] == LoopState.WORKING.value + assert st["queued_count"] == 1 + assert st["queued_messages"][0]["content"] == "queued user msg" + assert len(st["subagents"]) == 1 + sa = st["subagents"][0] + assert sa["id"] == sub_id + assert sa["task"] == "background scan" + assert sa["state"] == "running" + assert st["current_turn_id"] is not None + + await loop.cancel_subagents() + + +@pytest.mark.asyncio +async def test_status_empty_when_loop_idle(): + """An idle loop reports no subagents and an empty queue.""" + loop = AgentLoop() + st = loop.status() + assert st["state"] == LoopState.IDLE.value + assert st["queued_count"] == 0 + assert st["subagents"] == [] + assert st["queued_messages"] == [] + assert st["current_turn_id"] is None + + +# --------------------------------------------------------------------------- +# Long task in subagent while main loop stays responsive +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_subagent_runs_while_main_loop_accepts_another_message(): + """A long task runs in a subagent; the main loop still accepts messages. + + The subagent works in the background (WORKING state) while a second + message arrives and is queued -- the main loop is responsive (it does + not block on the subagent). When the subagent finishes its result is + available; queued messages are surfaced at the safe point. + """ + loop = AgentLoop() + await loop.handle_message("start") + + # Spawn a subagent that takes a moment. + async def worker(progress): + await asyncio.sleep(0.05) + progress({"kind": "delta", "content": "subagent made progress"}) + return "subagent result" + + sub_id = await loop.spawn_subagent("long computation", worker) + assert loop.get_subagent(sub_id).state == "running" + + # While the subagent runs, another message arrives -> queued (not blocked). + action = await loop.handle_message("are you still there?") + assert action == LoopAction.QUEUED + assert len(loop.message_queue) == 1 + + # Wait for the subagent to finish. + result = await loop.await_subagent(sub_id) + assert result == "subagent result" + assert loop.get_subagent(sub_id).state == "completed" + + # Now reach the safe point; the queued message is delivered. + delivered = await loop.reach_safe_point() + assert len(delivered) == 1 + assert delivered[0].content == "are you still there?" + + +# --------------------------------------------------------------------------- +# get_subagent returns None for unknown ids +# --------------------------------------------------------------------------- + +def test_get_subagent_unknown_returns_none(): + """Looking up an unknown subagent id returns None synchronously.""" + loop = AgentLoop() + assert loop.get_subagent("nope") is None + + +# --------------------------------------------------------------------------- +# timeout-safe awaits: a timed-out poll must not cancel the subagent +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_await_subagent_timeout_does_not_cancel_subagent(): + """A timeout on await_subagent does not cancel the underlying subagent task.""" + loop = AgentLoop() + started = asyncio.Event() + cancelled_flag = asyncio.Event() + + sub_id = await loop.spawn_subagent( + "long work", lambda p: _sleepy_worker(started, cancelled_flag), + ) + await started.wait() + assert loop.get_subagent(sub_id).state == "running" + + with pytest.raises(asyncio.TimeoutError): + await loop.await_subagent(sub_id, timeout=0.05) + + assert loop.get_subagent(sub_id).state == "running" + assert not cancelled_flag.is_set() + + await loop.cancel_subagents() + + +@pytest.mark.asyncio +async def test_await_all_subagents_timeout_does_not_cancel_subagents(): + """await_all_subagents with a short timeout does not cancel running subagents.""" + loop = AgentLoop() + started = asyncio.Event() + cancelled_flag = asyncio.Event() + + sub_id = await loop.spawn_subagent( + "long work", lambda p: _sleepy_worker(started, cancelled_flag), + ) + await started.wait() + assert loop.get_subagent(sub_id).state == "running" + + with pytest.raises(asyncio.TimeoutError): + await loop.await_all_subagents(timeout=0.05) + + assert loop.get_subagent(sub_id).state == "running" + assert not cancelled_flag.is_set() + + await loop.cancel_subagents() + + +# --------------------------------------------------------------------------- +# safe-point race: message arriving during cancel window is not dropped +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_message_arriving_during_cancel_window_is_not_dropped_and_no_concurrent_turn(): + """A message that arrives while reach_safe_point is awaiting cancel_subagents + is queued safely and delivered; no concurrent turn is started.""" + loop = AgentLoop() + await loop.handle_message("start a long build") + + started = asyncio.Event() + cancelled_flag = asyncio.Event() + + sub_id = await loop.spawn_subagent( + "building artifacts", lambda p: _slow_cancel_worker(started, cancelled_flag), + ) + await started.wait() + assert loop.get_subagent(sub_id).state == "running" + + await loop.handle_message("abort and restart", is_redirect=True) + assert loop.has_pending_redirect() is True + + async def _do_safe_point(): + return await loop.reach_safe_point() + + safe_point_task = asyncio.create_task(_do_safe_point()) + await asyncio.sleep(0.05) + + action = await loop.handle_message("message during cancel") + assert action == LoopAction.QUEUED + + delivered = await safe_point_task + + assert any(m.content == "message during cancel" for m in delivered) + assert loop.state == LoopState.IDLE + assert loop.current_turn_id is None diff --git a/tinyagentos/agent_loop.py b/tinyagentos/agent_loop.py new file mode 100644 index 000000000..8d26139f5 --- /dev/null +++ b/tinyagentos/agent_loop.py @@ -0,0 +1,487 @@ +"""Agent loop: subagent delegation + safe-point message queue. + +The main chat agent delegates heavy/long work to subagents so its own loop +stays free to present results and accept user interrupts/redirects. Messages +that arrive while the agent is working are queued (never dropped, never +applied mid-step) and surfaced at the next safe boundary -- a turn is atomic, +so interrupting mid-tool-call corrupts state. A redirect cancels in-flight +subagent work at that safe boundary. + +Public surface: + AgentLoop -- the loop controller + LoopState -- idle / working / safe_point + LoopAction -- immediate / queued (return of ``handle_message``) + QueuedMessage -- a buffered user message + SubagentHandle -- status of a spawned subagent + ProgressCallback -- callable for streaming subagent progress + SubagentWorker -- async callable that performs a subagent's work +""" +from __future__ import annotations + +import asyncio +import enum +import logging +import time +import uuid +from dataclasses import dataclass, field +from typing import Any, Awaitable, Callable + +from tinyagentos.task_utils import _create_supervised_task, cancel_and_wait + +logger = logging.getLogger(__name__) + +# A sync callable that a subagent invokes to stream partial results (reply +# dicts with ``kind``/``content``/etc.) back to the main loop's sink. +ProgressCallback = Callable[[dict], None] + +# An async callable that does the subagent's heavy work. It receives a +# progress callback so it can stream intermediate results without blocking the +# main loop. Returning a value completes the subagent with that result. +SubagentWorker = Callable[[ProgressCallback], Awaitable[Any]] + + +# --------------------------------------------------------------------------- +# Enums and dataclasses +# --------------------------------------------------------------------------- + +class LoopState(enum.Enum): + """High-level loop state at any moment. + + IDLE -- no turn in progress; new messages start a turn immediately. + WORKING -- a turn (or one or more subagents) is in flight; new + messages are queued for the next safe point. + SAFE_POINT -- transient; the turn just ended and queued messages are + being surfaced. The loop returns to IDLE right after. + """ + + IDLE = "idle" + WORKING = "working" + SAFE_POINT = "safe_point" + + +class LoopAction(enum.Enum): + """What :meth:`AgentLoop.handle_message` decided to do with a message.""" + + IMMEDIATE = "immediate" + QUEUED = "queued" + + +@dataclass +class QueuedMessage: + """A user message buffered while the loop was busy. + + ``is_redirect`` marks a message that, when surfaced at a safe point, + cancels in-flight subagent work before the next turn starts. + """ + + id: str + content: str + received_at: float + is_redirect: bool = False + + +@dataclass +class SubagentHandle: + """Tracks a spawned subagent from creation through completion. + + ``state`` is one of ``"running"``, ``"completed"``, ``"cancelled"`` or + ``"failed"``. ``result``/``error`` are populated on the latter three. + """ + + id: str + task: str + state: str = "running" + result: Any = None + error: str | None = None + started_at: float = field(default_factory=time.time) + + +class _SubagentEntry: + """Internal pair of a handle and its backing asyncio task.""" + + __slots__ = ("handle", "task_obj") + + def __init__( + self, + handle: SubagentHandle, + task_obj: asyncio.Task | None = None, + ) -> None: + self.handle = handle + self.task_obj = task_obj + + +# --------------------------------------------------------------------------- +# AgentLoop +# --------------------------------------------------------------------------- + +class AgentLoop: + """Main agent loop with subagent delegation and safe-point message queue. + + Responsibilities (matching the task spec): + (1) Agents can spawn subagents for long tasks so the main loop stays + responsive. + (2) Messages arriving while working are queued -- never dropped, never + applied mid-step. + (3) At the next safe boundary queued messages are surfaced. A redirect + among them cancels in-flight subagent work. + (4) ``status()`` lets the user see what is running and what is queued. + + A *turn* is atomic: once ``handle_message`` returns ``IMMEDIATE`` the loop + is in ``WORKING`` state until :meth:`reach_safe_point` is called. Any + message that arrives in between is buffered. The caller drives the turn + (e.g. via :func:`tinyagentos.opencode_runtime.drive_turn` or an ACP turn) + and calls :meth:`reach_safe_point` at the end to drain the queue. + """ + + def __init__(self, sink: Callable[[dict], Any] | None = None) -> None: + """Create a new agent loop. + + Args: + sink: Optional async-or-sync callable that receives subagent progress + dicts (same reply-dict shape used elsewhere in taOS). May be + None if no streaming is required. + """ + self._state: LoopState = LoopState.IDLE + self._message_queue: list[QueuedMessage] = [] + self._subagents: dict[str, _SubagentEntry] = {} + self._lock = asyncio.Lock() + self._sink = sink + # Messages surfaced at safe points, retained for inspection. + self._delivered: list[QueuedMessage] = [] + self._current_turn_id: str | None = None + self._subagent_tasks: set[asyncio.Task] = set() + self._max_delivered = 1000 + self._max_subagents = 100 + + # ----------------------------------------------------------------- state + + @property + def state(self) -> LoopState: + """Current loop state.""" + return self._state + + @property + def current_turn_id(self) -> str | None: + """The message id at the head of the current turn, or None when idle.""" + return self._current_turn_id + + @property + def message_queue(self) -> list[QueuedMessage]: + """Snapshot of messages currently queued (not yet surfaced).""" + return list(self._message_queue) + + @property + def delivered(self) -> list[QueuedMessage]: + """Messages surfaced at safe points, in delivery order.""" + return list(self._delivered) + + def has_pending_redirect(self) -> bool: + """True if a redirect is buffered and waiting at the next safe point.""" + return any(m.is_redirect for m in self._message_queue) + + def _prune_subagents(self) -> None: + if len(self._subagents) <= self._max_subagents: + return + to_remove = [ + sid for sid, entry in self._subagents.items() + if entry.task_obj is not None and entry.task_obj.done() + ] + for sid in to_remove: + del self._subagents[sid] + + def _prune_delivered(self) -> None: + if len(self._delivered) > self._max_delivered: + self._delivered = self._delivered[-self._max_delivered:] + + # ------------------------------------------------------ message handling + + async def handle_message( + self, + content: str, + msg_id: str | None = None, + is_redirect: bool = False, + ) -> LoopAction: + """Accept a user message for the main agent. + + If the loop is IDLE, the message starts a new turn immediately and the + loop transitions to ``WORKING`` (returns ``IMMEDIATE``). The caller then + drives the turn and eventually calls :meth:`reach_safe_point`. + + If the loop is already ``WORKING``, the message is appended to the + queue and delivered at the next safe point (returns ``QUEUED``). + It is never dropped and never applied mid-step. + """ + msg_id = msg_id or uuid.uuid4().hex + msg = QueuedMessage( + id=msg_id, + content=content, + received_at=time.time(), + is_redirect=is_redirect, + ) + async with self._lock: + if self._state in (LoopState.WORKING, LoopState.SAFE_POINT): + self._message_queue.append(msg) + return LoopAction.QUEUED + # IDLE -> start a new turn. The queue is already drained by + # reach_safe_point, so nothing is discarded here. + self._state = LoopState.WORKING + self._current_turn_id = msg_id + return LoopAction.IMMEDIATE + + # ----------------------------------------------------- subagent lifecycle + + async def spawn_subagent( + self, + task: str, + worker: SubagentWorker, + ) -> str: + """Spawn a subagent for heavy/long work. + + The subagent runs concurrently as a supervised background task. The + main loop stays responsive -- it can accept messages, surface results + via ``sink``, and eventually reach a safe point -- while the subagent + works. + + Args: + task: Human-readable description of the delegated work. + worker: ``async def worker(progress: ProgressCallback) -> result`` + that performs the work. Call ``progress({...})`` to stream + partial results to the main loop's sink. + + Returns: + The subagent id (use with :meth:`await_subagent` or :meth:`status`). + """ + sub_id = uuid.uuid4().hex[:12] + handle = SubagentHandle(id=sub_id, task=task, state="running") + entry = _SubagentEntry(handle) + async with self._lock: + self._subagents[sub_id] = entry + + def _progress(msg: dict) -> None: + """Forward subagent progress to the main loop's sink.""" + if self._sink is None: + return + try: + res = self._sink(msg) + if asyncio.iscoroutine(res): + _create_supervised_task(res, self._subagent_tasks) + except Exception: + logger.exception("subagent %s: progress sink raised", sub_id) + + async def _runner() -> None: + try: + result = await worker(_progress) + async with self._lock: + handle.state = "completed" + handle.result = result + except asyncio.CancelledError: + async with self._lock: + handle.state = "cancelled" + raise + except Exception as exc: + async with self._lock: + handle.state = "failed" + handle.error = str(exc) + logger.exception("subagent %s (%s) failed", sub_id, task) + + task_obj = _create_supervised_task(_runner(), self._subagent_tasks) + task_obj.set_name(f"subagent:{sub_id}") + entry.task_obj = task_obj + self._prune_subagents() + logger.debug("subagent %s spawned for task=%s", sub_id, task) + return sub_id + + async def cancel_subagents(self, reason: str | None = None) -> int: + """Cancel all in-flight subagents. + + Called when a redirect arrives at a safe boundary. Cancellation is + requested on every running subagent task and awaited with a bounded + timeout so a misbehaving worker cannot block the loop forever. The + worker itself decides how to unwind (cooperative cancellation via + ``CancelledError``). + + Returns: + The number of subagents that were running and had cancellation + requested. + """ + to_cancel: list[asyncio.Task] = [] + async with self._lock: + for entry in self._subagents.values(): + if entry.handle.state == "running" and entry.task_obj is not None: + if not entry.task_obj.done(): + to_cancel.append(entry.task_obj) + entry.task_obj.cancel() + # Wait outside the lock so the loop isn't held while workers unwind. + if to_cancel: + stragglers = await cancel_and_wait(to_cancel, timeout=10.0) + for t in stragglers: + logger.warning( + "subagent %s did not exit within 10s after cancel", + t.get_name(), + ) + count = len(to_cancel) + if reason: + logger.info("subagent: cancelled %d task(s) -- %s", count, reason) + return count + + # --------------------------------------------------------- safe boundary + + async def reach_safe_point(self) -> list[QueuedMessage]: + """Transition to SAFE_POINT and surface queued messages. + + Called at a turn boundary: the turn is atomic and complete. The loop + moves ``WORKING`` -> ``SAFE_POINT``, drains the message queue so the + caller can act on each buffered message, then returns to ``IDLE`` so + new messages are processed immediately on the next turn. + + Messages that arrive while ``cancel_subagents`` is running are queued + safely (the loop stays in ``SAFE_POINT``) and included in the returned + list. + + Returns: + The queued messages to surface, in arrival order. Callers iterate + these and start a fresh turn for each (or redirect as appropriate). + """ + async with self._lock: + self._state = LoopState.SAFE_POINT + queued = list(self._message_queue) + self._message_queue.clear() + + # If a redirect is among the queued messages, cancel subagents at this + # safe boundary (never mid-turn). + if any(m.is_redirect for m in queued): + await self.cancel_subagents(reason="redirect at safe point") + + async with self._lock: + queued.extend(self._message_queue) + self._message_queue.clear() + if self._state == LoopState.SAFE_POINT: + self._state = LoopState.IDLE + self._current_turn_id = None + self._delivered.extend(queued) + self._prune_delivered() + return queued + + # --------------------------------------------------- visibility / status + + def status(self) -> dict[str, Any]: + """Return what is running and what is queued (for UI visibility). + + Lets the user see: + - ``state``: current loop state (idle / working / safe_point) + - ``current_turn_id``: the message id driving the current turn + - ``subagents``: list of subagent descriptors (id, task, state, ...) + - ``queued_messages``: list of buffered message descriptors + - ``queued_count``: how many messages are buffered + + Safe to call from any context (no I/O, no await). + """ + subagents: list[dict[str, Any]] = [] + for sid, entry in self._subagents.items(): + h = entry.handle + subagents.append({ + "id": sid, + "task": h.task, + "state": h.state, + "started_at": h.started_at, + "result": h.result, + "error": h.error, + }) + queued: list[dict[str, Any]] = [ + { + "id": m.id, + "content": m.content, + "received_at": m.received_at, + "is_redirect": m.is_redirect, + } + for m in self._message_queue + ] + return { + "state": self._state.value, + "current_turn_id": self._current_turn_id, + "subagents": subagents, + "queued_messages": queued, + "queued_count": len(self._message_queue), + } + + # --------------------------------------------------- waiting for subagents + + async def await_subagent( + self, + sub_id: str, + timeout: float | None = None, + ) -> Any: + """Wait for a subagent to finish and return its result. + + If the subagent was cancelled (e.g. via ``cancel_subagents`` or a + redirect at a safe point), the handle state is ``"cancelled"`` and + the result (``None``) is returned without re-raising. + + A *timeout* only limits how long this call waits. It does NOT cancel + the subagent task. The subagent keeps running and can be awaited again + later. + + Raises: + KeyError: if *sub_id* is unknown. + asyncio.TimeoutError: if *timeout* is given and exceeded. + """ + async with self._lock: + entry = self._subagents.get(sub_id) + if entry is None or entry.task_obj is None: + raise KeyError(sub_id) + if entry.task_obj.done(): + if entry.handle.state == "cancelled": + return entry.handle.result + return entry.handle.result + done, pending = await asyncio.wait([entry.task_obj], timeout=timeout) + if entry.task_obj in pending: + raise asyncio.TimeoutError( + f"subagent {sub_id} did not finish within {timeout}s" + ) + if entry.handle.state == "cancelled": + return entry.handle.result + return entry.handle.result + + def get_subagent(self, sub_id: str) -> SubagentHandle | None: + """Return a snapshot of a subagent's handle, or None if unknown.""" + entry = self._subagents.get(sub_id) + if entry is None: + return None + h = entry.handle + return SubagentHandle( + id=h.id, + task=h.task, + state=h.state, + result=h.result, + error=h.error, + started_at=h.started_at, + ) + + async def await_all_subagents(self, timeout: float | None = None) -> None: + """Wait for all running subagents to settle (success or cancel). + + Does not cancel them -- just waits. A *timeout* only limits how long + this call waits. It does NOT cancel any subagent tasks. Tasks keep + running and can be awaited individually after the timeout. + + Useful before the loop can safely reach a safe point and go idle. + + Raises: + asyncio.TimeoutError: if *timeout* is given and any subagent did + not finish within it. + """ + async with self._lock: + tasks = [ + e.task_obj + for e in self._subagents.values() + if e.task_obj is not None and not e.task_obj.done() + ] + if not tasks: + return None + done, pending = await asyncio.wait(tasks, timeout=timeout) + if pending: + raise asyncio.TimeoutError( + f"{len(pending)} subagent(s) did not finish within {timeout}s" + ) + self._prune_subagents() + return None From ff11a819e92072d45e1aadc502028f35c2107772 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Sun, 9 Aug 2026 22:58:05 +0000 Subject: [PATCH 07/56] feat(hailo): slice 6 model catalog manifests for Hailo-10H .hef chat models (per design doc) --- .../manifest.yaml | 28 ++++++++++++++++++ .../llama3.2-3b-instruct-hef/manifest.yaml | 28 ++++++++++++++++++ .../qwen2-1.5b-instruct-hef/manifest.yaml | 27 +++++++++++++++++ .../qwen2.5-1.5b-instruct-hef/manifest.yaml | 28 ++++++++++++++++++ .../manifest.yaml | 29 +++++++++++++++++++ 5 files changed, 140 insertions(+) create mode 100644 app-catalog/models/deepseek-r1-distill-qwen-1.5b-hef/manifest.yaml create mode 100644 app-catalog/models/llama3.2-3b-instruct-hef/manifest.yaml create mode 100644 app-catalog/models/qwen2-1.5b-instruct-hef/manifest.yaml create mode 100644 app-catalog/models/qwen2.5-1.5b-instruct-hef/manifest.yaml create mode 100644 app-catalog/models/qwen2.5-coder-1.5b-instruct-hef/manifest.yaml diff --git a/app-catalog/models/deepseek-r1-distill-qwen-1.5b-hef/manifest.yaml b/app-catalog/models/deepseek-r1-distill-qwen-1.5b-hef/manifest.yaml new file mode 100644 index 000000000..3af4e75e0 --- /dev/null +++ b/app-catalog/models/deepseek-r1-distill-qwen-1.5b-hef/manifest.yaml @@ -0,0 +1,28 @@ +id: deepseek-r1-distill-qwen-1.5b-hef +name: DeepSeek R1 Distill Qwen 1.5B (HEF) +type: model +version: 1.5.0 +description: "Hailo-10H NPU-accelerated DeepSeek R1 Distill Qwen 1.5B — runs on Raspberry Pi 5 + AI HAT+2 via hailo-ollama" +homepage: https://huggingface.co/deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B +license: MIT +capabilities: +- chat +- reasoning +variants: +- id: a8w4 + name: A8W4 HEF (2.4GB, NPU) + format: hef + size_mb: 2427 + download_url: https://dev-public.hailo.ai/v5.1.1/blob/DeepSeek-R1-Distill-Qwen-1.5B.hef + # sha256: fill in from the pinned HEF file before shipping to users + sha256: '' + requires: + backends: + - id: hailo-ollama + min_ram_mb: 2048 +hardware_tiers: + arm-npu-8gb: + recommended: a8w4 + arm-npu-16gb: + recommended: a8w4 +context_window: 2048 diff --git a/app-catalog/models/llama3.2-3b-instruct-hef/manifest.yaml b/app-catalog/models/llama3.2-3b-instruct-hef/manifest.yaml new file mode 100644 index 000000000..ff89b9f58 --- /dev/null +++ b/app-catalog/models/llama3.2-3b-instruct-hef/manifest.yaml @@ -0,0 +1,28 @@ +id: llama3.2-3b-instruct-hef +name: Llama 3.2 3B Instruct (HEF) +type: model +version: 3.2.0 +description: "Hailo-10H NPU-accelerated Llama 3.2 3B Instruct — runs on Raspberry Pi 5 + AI HAT+2 via hailo-ollama" +homepage: https://huggingface.co/meta-llama/Llama-3.2-3B +license: llama3.2 +capabilities: +- chat +variants: +- id: a8w4 + name: A8W4 HEF (3.2GB, NPU) + format: hef + size_mb: 3205 + download_url: https://dev-public.hailo.ai/v5.1.1/blob/Llama-3_2-3B-Instruct.hef + # sha256: fill in from the pinned HEF file before shipping to users + # Accuracy is under active optimization in the v5.1.1 Hailo Model Zoo release. + sha256: '' + requires: + backends: + - id: hailo-ollama + min_ram_mb: 3072 +hardware_tiers: + arm-npu-8gb: + recommended: a8w4 + arm-npu-16gb: + recommended: a8w4 +context_window: 2048 diff --git a/app-catalog/models/qwen2-1.5b-instruct-hef/manifest.yaml b/app-catalog/models/qwen2-1.5b-instruct-hef/manifest.yaml new file mode 100644 index 000000000..460ba321d --- /dev/null +++ b/app-catalog/models/qwen2-1.5b-instruct-hef/manifest.yaml @@ -0,0 +1,27 @@ +id: qwen2-1.5b-instruct-hef +name: Qwen2 1.5B Instruct (HEF) +type: model +version: 2.0.0 +description: "Hailo-10H NPU-accelerated Qwen2 1.5B Instruct — runs on Raspberry Pi 5 + AI HAT+2 via hailo-ollama" +homepage: https://huggingface.co/Qwen/Qwen2-1.5B-Instruct +license: Apache-2.0 +capabilities: +- chat +variants: +- id: a8w4 + name: A8W4 HEF (1.6GB, NPU) + format: hef + size_mb: 1597 + download_url: https://dev-public.hailo.ai/v5.1.1/blob/Qwen2-1.5B-Instruct.hef + # sha256: fill in from the pinned HEF file before shipping to users + sha256: '' + requires: + backends: + - id: hailo-ollama + min_ram_mb: 2048 +hardware_tiers: + arm-npu-8gb: + recommended: a8w4 + arm-npu-16gb: + recommended: a8w4 +context_window: 2048 diff --git a/app-catalog/models/qwen2.5-1.5b-instruct-hef/manifest.yaml b/app-catalog/models/qwen2.5-1.5b-instruct-hef/manifest.yaml new file mode 100644 index 000000000..8768c4b58 --- /dev/null +++ b/app-catalog/models/qwen2.5-1.5b-instruct-hef/manifest.yaml @@ -0,0 +1,28 @@ +id: qwen2.5-1.5b-instruct-hef +name: Qwen 2.5 1.5B Instruct (HEF) +type: model +version: 2.5.0 +description: "Hailo-10H NPU-accelerated Qwen 2.5 1.5B Instruct — runs on Raspberry Pi 5 + AI HAT+2 via hailo-ollama" +homepage: https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct +license: Apache-2.0 +capabilities: +- chat +- tool-calling +variants: +- id: a8w4 + name: A8W4 HEF (1.7GB, NPU) + format: hef + size_mb: 1679 + download_url: https://dev-public.hailo.ai/v5.1.1/blob/Qwen2.5-1.5B-Instruct.hef + # sha256: fill in from the pinned HEF file before shipping to users + sha256: '' + requires: + backends: + - id: hailo-ollama + min_ram_mb: 2048 +hardware_tiers: + arm-npu-8gb: + recommended: a8w4 + arm-npu-16gb: + recommended: a8w4 +context_window: 2048 diff --git a/app-catalog/models/qwen2.5-coder-1.5b-instruct-hef/manifest.yaml b/app-catalog/models/qwen2.5-coder-1.5b-instruct-hef/manifest.yaml new file mode 100644 index 000000000..8093f056e --- /dev/null +++ b/app-catalog/models/qwen2.5-coder-1.5b-instruct-hef/manifest.yaml @@ -0,0 +1,29 @@ +id: qwen2.5-coder-1.5b-instruct-hef +name: Qwen 2.5 Coder 1.5B Instruct (HEF) +type: model +version: 1.5.0 +description: "Hailo-10H NPU-accelerated Qwen 2.5 Coder 1.5B Instruct — runs on Raspberry Pi 5 + AI HAT+2 via hailo-ollama" +homepage: https://huggingface.co/Qwen/Qwen2.5-Coder-1.5B +license: Apache-2.0 +capabilities: +- chat +- tool-calling +- code +variants: +- id: a8w4 + name: A8W4 HEF (1.7GB, NPU) + format: hef + size_mb: 1679 + download_url: https://dev-public.hailo.ai/v5.1.1/blob/Qwen2.5-Coder-1.5B-Instruct.hef + # sha256: fill in from the pinned HEF file before shipping to users + sha256: '' + requires: + backends: + - id: hailo-ollama + min_ram_mb: 2048 +hardware_tiers: + arm-npu-8gb: + recommended: a8w4 + arm-npu-16gb: + recommended: a8w4 +context_window: 2048 From d24510e8014cd1fdc306f7d6ec3fa021898497e0 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Sun, 9 Aug 2026 23:12:05 +0000 Subject: [PATCH 08/56] docs(agent-manual): add mechanical-simple-auditable design law Add the design law to 01-rules.md with a worked example anonymised as 'an agent', link it from index.md, and rebuild the compiled manual. Trim verbose prose in the image-prompting guide to stay within the compiled manual 18000-char budget. Update CHANGELOG. --- CHANGELOG.md | 7 +++ docs/agent-manual/01-rules.md | 13 +++- docs/agent-manual/10-image-prompting.md | 68 ++++++++------------- docs/agent-manual/index.md | 2 +- docs/taos-agent-manual.md | 79 ++++++++++++------------- 5 files changed, 83 insertions(+), 86 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e074a8320..f4cfcac24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ Versions follow semver beta: `1.0.0-beta.N`, bumped on each dev->master promotio ## [Unreleased] +### Added + +- **Docs**: mechanical-simple-auditable design law added to the agent manual + (`01-rules.md`), with a worked example anonymised as "an agent". Also trimmed + verbose prose in the image-prompting guide to stay within the compiled manual + character budget. + ## [1.0.0-beta.47] - 2026-08-09 ### Added diff --git a/docs/agent-manual/01-rules.md b/docs/agent-manual/01-rules.md index 1bb88f9ce..9ce0cf696 100644 --- a/docs/agent-manual/01-rules.md +++ b/docs/agent-manual/01-rules.md @@ -1,6 +1,6 @@ # Rules - + ## Absolute rules @@ -17,3 +17,14 @@ - Never tell a user to edit config files or run terminal commands as the FIRST answer if a Settings path exists. UI first, terminal as fallback. - Never claim taOS collects analytics, accounts, or personal data. It does not. - Never speak for the user's other agents or pretend to be one of them. + +## Design law: mechanical, simple, auditable + +1. PREFER A MECHANISM OVER A PROMPT. A rule you must remember is a preference; a check that refuses is a guarantee. +2. THEN PREFER THE SIMPLEST MECHANISM THAT WORKS. Mechanical does not mean elaborate. Count the moving parts. Complexity you add is complexity you debug later. +3. USE REALTIME PUSH AND NOTIFICATIONS where the platform offers them rather than a poller you maintain yourself. If something can notify you, let it. +4. TWO TESTS before building: AUDITABLE (can you see WHAT happened afterwards, from a record that survives?) and DIAGNOSABLE (when it fails, can you tell WHY from ONE place?). +5. THE WARNING SIGN: if you are chaining components to simulate something ONE CALL would do, stop and find the direct call. Async coordination faking synchronous request/response is a recurring anti-pattern here. +6. Applies to WORKFLOWS AND PROCESSES too, not only code: monitoring, health checks, handoffs, escalation. + +**Worked example**: an agent needed to know when a job finished, so it chained five moving parts -- a stream watcher, a spool file, a cron, a ticker, and a polling loop -- to simulate a return value by polling. One synchronous call to the job's status endpoint was the answer. The chain was auditable only by stitching four different logs, and failed in five different ways. diff --git a/docs/agent-manual/10-image-prompting.md b/docs/agent-manual/10-image-prompting.md index 7c16336f8..3f6afc524 100644 --- a/docs/agent-manual/10-image-prompting.md +++ b/docs/agent-manual/10-image-prompting.md @@ -2,14 +2,12 @@ # Generating good images -When you call `generate_image`, the quality of the result depends mostly on the -prompt. A vague prompt gives a generic image; a specific, well-ordered one gives -what the user actually asked for. Spend a sentence getting it right rather than +Prompt quality drives results. Spend a sentence getting it right rather than regenerating five times. ## Structure a prompt -Lead with the subject, then layer detail. A reliable order: +A reliable order: 1. **Subject** — what it is. "a small red sailboat", "a friendly cartoon fox". 2. **Descriptors** — appearance, colour, material, mood. "weathered wooden hull, @@ -21,24 +19,19 @@ Lead with the subject, then layer detail. A reliable order: than any other single word. 6. **Lighting / quality** — "soft warm light, gentle shadows, highly detailed". -Example: `a friendly cartoon fox reading a book under a tree, autumn leaves, -warm soft light, watercolour children's book illustration, centred, highly detailed`. +Example: `a friendly cartoon fox under a tree, autumn leaves, warm light, watercolour +illustration, centred, highly detailed`. ## Principles -- **Be specific, not long.** Concrete nouns and adjectives beat a wall of vague - words. "golden retriever puppy on grass" beats "a nice cute lovely beautiful - amazing dog". -- **Front-load what matters.** Earlier words carry more weight. Put the subject - and the must-have details first. -- **One clear scene.** Don't pack several unrelated ideas into one prompt; the - model blends them into mush. Generate separate images instead. -- **Name the style explicitly.** If the user wants a storybook look, say - "children's book illustration" or "storybook watercolour". If they want a logo, - say "flat minimalist vector logo". -- **Match the user's intent.** Ask yourself what they pictured and describe that, - not a generic version of it. For a book cover, say "book cover, title space at - the top, central character". +- **Be specific, not long.** Concrete nouns and adjectives beat a wall of vague words. +- **Front-load what matters.** Earlier words carry more weight; put the subject and + must-have details first. +- **One clear scene.** Don't pack unrelated ideas into one prompt; the model blends + them into mush. Generate separate images instead. +- **Name the style explicitly.** For a storybook look, say "children's book + illustration" or "storybook watercolour"; for a logo, say "flat minimalist vector logo". +- **Match the user's intent.** Describe what they pictured, not a generic version. ## Use negative_prompt to remove faults @@ -47,38 +40,29 @@ defects: - General cleanup: `blurry, low quality, jpeg artifacts, watermark, text, signature`. - People/animals: add `deformed hands, extra fingers, extra limbs, mutated`. -- Keep a clean style: add `cluttered, busy background` if you want simplicity. - -Reach for it when a first result has a recurring flaw rather than rewriting the -whole prompt. +- Keep a clean style: add `cluttered, busy background` for simplicity. ## Parameters (what the tool exposes) -- **size** — `256x256`, `384x384`, or `512x512`. Use 512x512 for the final - artwork; a smaller size is only worth it for a quick rough draft. -- **steps** — 1 to 8 (default 4). These backends are tuned for few-step - generation; 4 is a good balance, 6 to 8 for a bit more detail. More is not - always better here. -- **guidance_scale** — 1 to 20 (default 7.5). How strictly the image follows the - prompt. Lower (2 to 5) is looser and more artistic; higher (8 to 12) sticks to - the prompt harder. Raise it when the model ignores a detail you asked for; - lower it if results look over-baked or harsh. -- **seed** — omit for a fresh random image. To make small edits to an image the - user liked, reuse its returned `seed` and tweak the prompt so the composition - stays close. -- **model** — call `describe_image_capabilities` first and pick a model that fits - the task: a fast NPU draft model for iterating, a GPU model for the final cover. - Omit it to let the scheduler choose. +- **size** — `256x256`, `384x384`, or `512x512`. Use 512x512 for final artwork; + smaller only for a quick draft. +- **steps** — 1 to 8 (default 4). 4 is a good balance; 6 to 8 for more detail. +- **guidance_scale** — 1 to 20 (default 7.5). Raise when the model ignores a + requested detail; lower if results look over-baked. +- **seed** — omit for a fresh image. To tweak a liked image, reuse its `seed` and + keep the prompt close. +- **model** — call `describe_image_capabilities` first; a fast NPU model for + drafting, a GPU model for the final cover. Omit to auto-pick. ## Picking a model by intent Model families differ: FLUX-style models follow full natural-language sentences; SDXL-style models like comma-separated phrases and strong style keywords. Text in -the image (a title or label) is unreliable on most models, so keep it short and -quoted, e.g. `a poster titled "Brave Little Fox"`. +the image is unreliable on most models, so keep it short and quoted, e.g. +`a poster titled "Brave Little Fox"`. ## Iterate deliberately If the first image is close but not right, change one thing at a time (a style -word, a missing detail, a negative term for the defect), keep the same seed, and -tell the user what you changed. +word, a missing detail, a negative term), keep the same seed, and tell the user +what changed. diff --git a/docs/agent-manual/index.md b/docs/agent-manual/index.md index 63f721184..94a5ae657 100644 --- a/docs/agent-manual/index.md +++ b/docs/agent-manual/index.md @@ -9,7 +9,7 @@ Run `python3 scripts/build-agent-manual.py` to compile these into `docs/taos-age | File | Contents | |---|---| | `00-identity.md` | Who the taOS agent is, persona, the "speak as taOS" voice | -| `01-rules.md` | Absolute rules, the do-not-know fallback line, hard things never to do | +| `01-rules.md` | Absolute rules, the do-not-know fallback line, hard things never to do, and the mechanical-simple-auditable design law | | `02-what-is-taos.md` | One-paragraph product description | | `03-facts.md` | Ports, frameworks, URLs, and install command facts table | | `04-apps.md` | One-line descriptions of every taOS app | diff --git a/docs/taos-agent-manual.md b/docs/taos-agent-manual.md index 315d9b1cf..e56326bc9 100644 --- a/docs/taos-agent-manual.md +++ b/docs/taos-agent-manual.md @@ -32,6 +32,17 @@ Your character, in four lines: - Never tell a user to edit config files or run terminal commands as the FIRST answer if a Settings path exists. UI first, terminal as fallback. - Never claim taOS collects analytics, accounts, or personal data. It does not. - Never speak for the user's other agents or pretend to be one of them. + +## Design law: mechanical, simple, auditable + +1. PREFER A MECHANISM OVER A PROMPT. A rule you must remember is a preference; a check that refuses is a guarantee. +2. THEN PREFER THE SIMPLEST MECHANISM THAT WORKS. Mechanical does not mean elaborate. Count the moving parts. Complexity you add is complexity you debug later. +3. USE REALTIME PUSH AND NOTIFICATIONS where the platform offers them rather than a poller you maintain yourself. If something can notify you, let it. +4. TWO TESTS before building: AUDITABLE (can you see WHAT happened afterwards, from a record that survives?) and DIAGNOSABLE (when it fails, can you tell WHY from ONE place?). +5. THE WARNING SIGN: if you are chaining components to simulate something ONE CALL would do, stop and find the direct call. Async coordination faking synchronous request/response is a recurring anti-pattern here. +6. Applies to WORKFLOWS AND PROCESSES too, not only code: monitoring, health checks, handoffs, escalation. + +**Worked example**: an agent needed to know when a job finished, so it chained five moving parts -- a stream watcher, a spool file, a cron, a ticker, and a polling loop -- to simulate a return value by polling. One synchronous call to the job's status endpoint was the answer. The chain was auditable only by stitching four different logs, and failed in five different ways. --- # What is taOS @@ -190,14 +201,12 @@ You can read and write shared notes and lists you belong to: # Generating good images -When you call `generate_image`, the quality of the result depends mostly on the -prompt. A vague prompt gives a generic image; a specific, well-ordered one gives -what the user actually asked for. Spend a sentence getting it right rather than +Prompt quality drives results. Spend a sentence getting it right rather than regenerating five times. ## Structure a prompt -Lead with the subject, then layer detail. A reliable order: +A reliable order: 1. **Subject** — what it is. "a small red sailboat", "a friendly cartoon fox". 2. **Descriptors** — appearance, colour, material, mood. "weathered wooden hull, @@ -209,24 +218,19 @@ Lead with the subject, then layer detail. A reliable order: than any other single word. 6. **Lighting / quality** — "soft warm light, gentle shadows, highly detailed". -Example: `a friendly cartoon fox reading a book under a tree, autumn leaves, -warm soft light, watercolour children's book illustration, centred, highly detailed`. +Example: `a friendly cartoon fox under a tree, autumn leaves, warm light, watercolour +illustration, centred, highly detailed`. ## Principles -- **Be specific, not long.** Concrete nouns and adjectives beat a wall of vague - words. "golden retriever puppy on grass" beats "a nice cute lovely beautiful - amazing dog". -- **Front-load what matters.** Earlier words carry more weight. Put the subject - and the must-have details first. -- **One clear scene.** Don't pack several unrelated ideas into one prompt; the - model blends them into mush. Generate separate images instead. -- **Name the style explicitly.** If the user wants a storybook look, say - "children's book illustration" or "storybook watercolour". If they want a logo, - say "flat minimalist vector logo". -- **Match the user's intent.** Ask yourself what they pictured and describe that, - not a generic version of it. For a book cover, say "book cover, title space at - the top, central character". +- **Be specific, not long.** Concrete nouns and adjectives beat a wall of vague words. +- **Front-load what matters.** Earlier words carry more weight; put the subject and + must-have details first. +- **One clear scene.** Don't pack unrelated ideas into one prompt; the model blends + them into mush. Generate separate images instead. +- **Name the style explicitly.** For a storybook look, say "children's book + illustration" or "storybook watercolour"; for a logo, say "flat minimalist vector logo". +- **Match the user's intent.** Describe what they pictured, not a generic version. ## Use negative_prompt to remove faults @@ -235,41 +239,32 @@ defects: - General cleanup: `blurry, low quality, jpeg artifacts, watermark, text, signature`. - People/animals: add `deformed hands, extra fingers, extra limbs, mutated`. -- Keep a clean style: add `cluttered, busy background` if you want simplicity. - -Reach for it when a first result has a recurring flaw rather than rewriting the -whole prompt. +- Keep a clean style: add `cluttered, busy background` for simplicity. ## Parameters (what the tool exposes) -- **size** — `256x256`, `384x384`, or `512x512`. Use 512x512 for the final - artwork; a smaller size is only worth it for a quick rough draft. -- **steps** — 1 to 8 (default 4). These backends are tuned for few-step - generation; 4 is a good balance, 6 to 8 for a bit more detail. More is not - always better here. -- **guidance_scale** — 1 to 20 (default 7.5). How strictly the image follows the - prompt. Lower (2 to 5) is looser and more artistic; higher (8 to 12) sticks to - the prompt harder. Raise it when the model ignores a detail you asked for; - lower it if results look over-baked or harsh. -- **seed** — omit for a fresh random image. To make small edits to an image the - user liked, reuse its returned `seed` and tweak the prompt so the composition - stays close. -- **model** — call `describe_image_capabilities` first and pick a model that fits - the task: a fast NPU draft model for iterating, a GPU model for the final cover. - Omit it to let the scheduler choose. +- **size** — `256x256`, `384x384`, or `512x512`. Use 512x512 for final artwork; + smaller only for a quick draft. +- **steps** — 1 to 8 (default 4). 4 is a good balance; 6 to 8 for more detail. +- **guidance_scale** — 1 to 20 (default 7.5). Raise when the model ignores a + requested detail; lower if results look over-baked. +- **seed** — omit for a fresh image. To tweak a liked image, reuse its `seed` and + keep the prompt close. +- **model** — call `describe_image_capabilities` first; a fast NPU model for + drafting, a GPU model for the final cover. Omit to auto-pick. ## Picking a model by intent Model families differ: FLUX-style models follow full natural-language sentences; SDXL-style models like comma-separated phrases and strong style keywords. Text in -the image (a title or label) is unreliable on most models, so keep it short and -quoted, e.g. `a poster titled "Brave Little Fox"`. +the image is unreliable on most models, so keep it short and quoted, e.g. +`a poster titled "Brave Little Fox"`. ## Iterate deliberately If the first image is close but not right, change one thing at a time (a style -word, a missing detail, a negative term for the defect), keep the same seed, and -tell the user what you changed. +word, a missing detail, a negative term), keep the same seed, and tell the user +what changed. --- # Project Files API From 11d7009312a52f020b7a57d070e86473e511f05e Mon Sep 17 00:00:00 2001 From: jaylfc Date: Sun, 9 Aug 2026 23:21:12 +0000 Subject: [PATCH 09/56] fix(auth): apply session User-Agent binding uniformly across auth surfaces /auth/status and /auth/me validated sessions without the User-Agent the API middleware checks, so a session whose UA hash stopped matching (a browser auto-update rotates the UA string) read authenticated on status while every /api/* call returned 401. The SPA's LoginGate treats that contradiction as session-expired, re-checks status, gets authenticated, remounts the shell, and loops - the PWA refresh loop observed on the beta.46 deployment. The chat, canvas, terminal and web-chat WebSocket handlers had the inverse hole: they accepted a cookie the APIs reject. All six call sites now pass the request's User-Agent, so the stolen- cookie binding check gives one answer everywhere. Sessions created without a UA hash continue to validate regardless, unchanged. --- changelog.d/fix-auth-status-ua-symmetry.md | 9 ++++ tests/test_auth.py | 50 ++++++++++++++++++++++ tinyagentos/routes/auth.py | 13 +++++- tinyagentos/routes/canvas.py | 8 +++- tinyagentos/routes/channel_hub.py | 8 +++- tinyagentos/routes/chat.py | 8 +++- tinyagentos/routes/terminal.py | 6 ++- 7 files changed, 96 insertions(+), 6 deletions(-) create mode 100644 changelog.d/fix-auth-status-ua-symmetry.md diff --git a/changelog.d/fix-auth-status-ua-symmetry.md b/changelog.d/fix-auth-status-ua-symmetry.md new file mode 100644 index 000000000..4c8bb7c50 --- /dev/null +++ b/changelog.d/fix-auth-status-ua-symmetry.md @@ -0,0 +1,9 @@ +### Fixed + +- **PWA refresh loop after a browser auto-update**: `/auth/status`, `/auth/me` + and the chat/canvas/terminal/web-chat WebSocket handlers now apply the same + session User-Agent binding check as the API middleware. Previously a session + created before a browser update kept reading as authenticated on + `/auth/status` while every `/api/*` call was rejected, so the desktop shell + remounted in a loop; the WebSocket endpoints conversely accepted a cookie + the APIs refused. diff --git a/tests/test_auth.py b/tests/test_auth.py index 27f19c1e7..17cba0b5a 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1211,3 +1211,53 @@ def test_session_with_ua_hash_validates_without_ua_param(self, tmp_path): # No user_agent param => skip check user_id = mgr.validate_session(token) assert user_id == rec["id"] + + +class TestAuthStatusUserAgentSymmetry: + """/auth/status and /auth/me must apply the SAME User-Agent binding check + as the API middleware. When they diverge, a session whose UA hash no + longer matches (browser auto-update rotates the UA string) reads + authenticated on /auth/status while every /api/* call 401s, and the SPA's + LoginGate remount-loops on the contradiction (beta.46 PWA refresh loop, + 2026-08-10).""" + + @pytest.mark.asyncio + async def test_status_rejects_session_with_rotated_user_agent(self, app, auth_client): + app.state.auth.set_password("passw0rd") + resp = await auth_client.post( + "/auth/login", + data={"password": "passw0rd"}, + headers={"user-agent": "TaosPWA/1.0 (old browser)"}, + follow_redirects=False, + ) + assert "taos_session" in resp.headers.get("set-cookie", "") + # Same UA -> still authenticated. + ok = await auth_client.get( + "/auth/status", headers={"user-agent": "TaosPWA/1.0 (old browser)"} + ) + assert ok.json()["authenticated"] is True + # Rotated UA (browser updated) -> the middleware would 401 every API + # call, so status must agree and report unauthenticated. + rotated = await auth_client.get( + "/auth/status", headers={"user-agent": "TaosPWA/2.0 (updated browser)"} + ) + assert rotated.json()["authenticated"] is False + + @pytest.mark.asyncio + async def test_me_rejects_session_with_rotated_user_agent(self, app, auth_client): + app.state.auth.set_password("passw0rd") + resp = await auth_client.post( + "/auth/login", + data={"password": "passw0rd"}, + headers={"user-agent": "TaosPWA/1.0 (old browser)"}, + follow_redirects=False, + ) + assert "taos_session" in resp.headers.get("set-cookie", "") + ok = await auth_client.get( + "/auth/me", headers={"user-agent": "TaosPWA/1.0 (old browser)"} + ) + assert ok.status_code != 401 + rotated = await auth_client.get( + "/auth/me", headers={"user-agent": "TaosPWA/2.0 (updated browser)"} + ) + assert rotated.status_code == 401 diff --git a/tinyagentos/routes/auth.py b/tinyagentos/routes/auth.py index b218a7e21..60f3bd687 100644 --- a/tinyagentos/routes/auth.py +++ b/tinyagentos/routes/auth.py @@ -654,7 +654,14 @@ async def auth_status(request: Request): auth_mgr = request.app.state.auth configured = auth_mgr.is_configured() token = request.cookies.get("taos_session", "") - user_id = auth_mgr.validate_session(token) if token else None + # Pass the request's User-Agent so the stolen-cookie binding check runs + # here exactly as it does in the API middleware. Without it a session + # whose UA hash no longer matches (browser auto-update rotated the UA) + # reads authenticated here while every /api/* call 401s, and the SPA's + # LoginGate remount-loops on that contradiction (the beta.46 PWA + # refresh-loop, 2026-08-10). + _ua = request.headers.get("user-agent", "") + user_id = auth_mgr.validate_session(token, user_agent=_ua) if token else None authenticated = user_id is not None user = None @@ -681,7 +688,9 @@ async def auth_me(request: Request): """Return the current user's profile. 401 when not signed in.""" auth_mgr = request.app.state.auth token = request.cookies.get("taos_session", "") - if not token or auth_mgr.validate_session(token) is None: + if not token or auth_mgr.validate_session( + token, user_agent=request.headers.get("user-agent", "") + ) is None: return JSONResponse({"error": "not authenticated"}, status_code=401) user = auth_mgr.get_user(token=token) if user is None: diff --git a/tinyagentos/routes/canvas.py b/tinyagentos/routes/canvas.py index 188198925..aed6da85a 100644 --- a/tinyagentos/routes/canvas.py +++ b/tinyagentos/routes/canvas.py @@ -79,7 +79,13 @@ async def canvas_ws(websocket: WebSocket, canvas_id: str): """WebSocket for live canvas updates.""" auth_mgr = websocket.app.state.auth token = websocket.cookies.get("taos_session", "") - user_id = auth_mgr.validate_session(token) if token else None + user_id = ( + auth_mgr.validate_session( + token, user_agent=websocket.headers.get("user-agent", "") + ) + if token + else None + ) if user_id is None: await websocket.close(code=1008) return diff --git a/tinyagentos/routes/channel_hub.py b/tinyagentos/routes/channel_hub.py index ba1238037..9bab81bc5 100644 --- a/tinyagentos/routes/channel_hub.py +++ b/tinyagentos/routes/channel_hub.py @@ -265,7 +265,13 @@ async def webchat_ws(websocket: WebSocket, agent_name: str): """WebSocket endpoint for web chat.""" auth_mgr = websocket.app.state.auth token = websocket.cookies.get("taos_session", "") - user_id = auth_mgr.validate_session(token) if token else None + user_id = ( + auth_mgr.validate_session( + token, user_agent=websocket.headers.get("user-agent", "") + ) + if token + else None + ) if user_id is None: await websocket.close(code=1008) return diff --git a/tinyagentos/routes/chat.py b/tinyagentos/routes/chat.py index c1590582e..4edfece95 100644 --- a/tinyagentos/routes/chat.py +++ b/tinyagentos/routes/chat.py @@ -111,7 +111,13 @@ async def get_chat_guide(): async def chat_ws(websocket: WebSocket): auth_mgr = websocket.app.state.auth token = websocket.cookies.get("taos_session", "") - user_id = auth_mgr.validate_session(token) if token else None + user_id = ( + auth_mgr.validate_session( + token, user_agent=websocket.headers.get("user-agent", "") + ) + if token + else None + ) if user_id is None: await websocket.close(code=1008) return diff --git a/tinyagentos/routes/terminal.py b/tinyagentos/routes/terminal.py index fa3fd593c..135c8fb5b 100644 --- a/tinyagentos/routes/terminal.py +++ b/tinyagentos/routes/terminal.py @@ -23,7 +23,11 @@ def _ws_session_user_id(websocket: WebSocket) -> str | None: token = websocket.cookies.get("taos_session", "") if not token: return None - return auth_mgr.validate_session(token) + # Same UA-binding check as the API middleware: the terminal is the most + # sensitive surface, it must not accept a cookie the APIs reject. + return auth_mgr.validate_session( + token, user_agent=websocket.headers.get("user-agent", "") + ) def build_command(config: dict) -> list[str]: From 6a6a9fff85218a57e864eeeafa4b61def35b5a30 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Mon, 10 Aug 2026 06:17:28 +0000 Subject: [PATCH 10/56] docs(skill): document the store-wiring gate + waiver trailer; retrigger gate on PR edits --- .claude/skills/taos-development-skill/SKILL.md | 16 +++++++++++++++- .github/workflows/store-wiring-gate.yml | 3 +++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/.claude/skills/taos-development-skill/SKILL.md b/.claude/skills/taos-development-skill/SKILL.md index 6a6979740..5ce8e6859 100644 --- a/.claude/skills/taos-development-skill/SKILL.md +++ b/.claude/skills/taos-development-skill/SKILL.md @@ -148,7 +148,7 @@ uv run pytest tests/ --ignore=tests/e2e -n auto - Uses `uv sync --frozen` and `pytest -n auto` - Also required: `spa-build` (npm build + tsc + **vitest** - a desktop type error or failing component test fails CI), a "Verify app starts" `create_app` import smoke, `lint` - (`compileall`), and `cla`. The doc-gate is a separate workflow. + (`compileall`), and `cla`. The doc-gate and store-wiring gate are separate workflows. ## CLA - HUMAN signs @@ -438,6 +438,20 @@ Docs-Reviewed: no user-facing change, internal refactor only Run `scripts/install-git-hooks.sh` to enable local hooks (`.githooks/pre-commit` and `.githooks/commit-msg`) so the gate runs before you push. +## Store wiring gate + +A gate (`.github/workflows/store-wiring-gate.yml`, running `scripts/check_store_wiring.py`) +blocks PRs that add a new `BaseStore` subclass without wiring it into `tinyagentos/app.py`. +Routes reach stores ONLY via `request.app.state`, so an unwired store is unreachable dead +code. The check is name-level (the class name must appear in `app.py`) and polices only +classes added by the PR - pre-existing orphans are skipped. + +For a store genuinely constructed elsewhere (tests, CLI, workers), waive it with a PR-body +trailer, which is logged by the gate: +``` +Store-Unwired-Intentionally: , +``` + ## Upstream conventions (from CONTRIBUTING.md) - **Target branch is `dev`, not `master`.** `master` is the stable live-install track. diff --git a/.github/workflows/store-wiring-gate.yml b/.github/workflows/store-wiring-gate.yml index e8f30aa9d..258191365 100644 --- a/.github/workflows/store-wiring-gate.yml +++ b/.github/workflows/store-wiring-gate.yml @@ -16,6 +16,9 @@ name: Store wiring gate on: pull_request: + # "edited" so a waiver trailer added by editing the PR body retriggers the + # gate (a re-run replays the stale event payload with the old body). + types: [opened, synchronize, reopened, edited] branches: [master, dev] jobs: From 292202b7adba2b0bf19edff2285e781d4212b6d0 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Mon, 10 Aug 2026 06:26:15 +0000 Subject: [PATCH 11/56] fix(hailo-catalog): wire hailo resolver target so HEF manifests can install The five Hailo-10H HEF manifests declared requires.backends without a targets list, so hardware_to_targets() never emitted a matching target and resolve() rejected every device, including a real Pi 5 + Hailo-10H. - hardware_to_targets(): add a hailo10h NPU branch emitting "hailo", mirroring the existing rockchip branch. - Each HEF manifest's hailo-ollama backend entry now declares targets: [hailo], matching the rkllm manifests' targets: [rockchip] convention. - Add tests/catalog/test_resolver_hailo.py: loads a real manifest from app-catalog and resolves it against a simulated Pi 5 + Hailo-10H profile (expects ResolveOk/hailo-ollama) and a CPU-only x86 profile (expects ResolveErr). --- .../manifest.yaml | 2 + .../llama3.2-3b-instruct-hef/manifest.yaml | 2 + .../qwen2-1.5b-instruct-hef/manifest.yaml | 2 + .../qwen2.5-1.5b-instruct-hef/manifest.yaml | 2 + .../manifest.yaml | 2 + tests/catalog/test_resolver_hailo.py | 74 +++++++++++++++++++ tinyagentos/cluster/capabilities.py | 2 + 7 files changed, 86 insertions(+) create mode 100644 tests/catalog/test_resolver_hailo.py diff --git a/app-catalog/models/deepseek-r1-distill-qwen-1.5b-hef/manifest.yaml b/app-catalog/models/deepseek-r1-distill-qwen-1.5b-hef/manifest.yaml index 3af4e75e0..8161b6fac 100644 --- a/app-catalog/models/deepseek-r1-distill-qwen-1.5b-hef/manifest.yaml +++ b/app-catalog/models/deepseek-r1-distill-qwen-1.5b-hef/manifest.yaml @@ -19,6 +19,8 @@ variants: requires: backends: - id: hailo-ollama + targets: + - hailo min_ram_mb: 2048 hardware_tiers: arm-npu-8gb: diff --git a/app-catalog/models/llama3.2-3b-instruct-hef/manifest.yaml b/app-catalog/models/llama3.2-3b-instruct-hef/manifest.yaml index ff89b9f58..5519e71fd 100644 --- a/app-catalog/models/llama3.2-3b-instruct-hef/manifest.yaml +++ b/app-catalog/models/llama3.2-3b-instruct-hef/manifest.yaml @@ -19,6 +19,8 @@ variants: requires: backends: - id: hailo-ollama + targets: + - hailo min_ram_mb: 3072 hardware_tiers: arm-npu-8gb: diff --git a/app-catalog/models/qwen2-1.5b-instruct-hef/manifest.yaml b/app-catalog/models/qwen2-1.5b-instruct-hef/manifest.yaml index 460ba321d..888cb4f3c 100644 --- a/app-catalog/models/qwen2-1.5b-instruct-hef/manifest.yaml +++ b/app-catalog/models/qwen2-1.5b-instruct-hef/manifest.yaml @@ -18,6 +18,8 @@ variants: requires: backends: - id: hailo-ollama + targets: + - hailo min_ram_mb: 2048 hardware_tiers: arm-npu-8gb: diff --git a/app-catalog/models/qwen2.5-1.5b-instruct-hef/manifest.yaml b/app-catalog/models/qwen2.5-1.5b-instruct-hef/manifest.yaml index 8768c4b58..a9dea3a6c 100644 --- a/app-catalog/models/qwen2.5-1.5b-instruct-hef/manifest.yaml +++ b/app-catalog/models/qwen2.5-1.5b-instruct-hef/manifest.yaml @@ -19,6 +19,8 @@ variants: requires: backends: - id: hailo-ollama + targets: + - hailo min_ram_mb: 2048 hardware_tiers: arm-npu-8gb: diff --git a/app-catalog/models/qwen2.5-coder-1.5b-instruct-hef/manifest.yaml b/app-catalog/models/qwen2.5-coder-1.5b-instruct-hef/manifest.yaml index 8093f056e..11e37c95d 100644 --- a/app-catalog/models/qwen2.5-coder-1.5b-instruct-hef/manifest.yaml +++ b/app-catalog/models/qwen2.5-coder-1.5b-instruct-hef/manifest.yaml @@ -20,6 +20,8 @@ variants: requires: backends: - id: hailo-ollama + targets: + - hailo min_ram_mb: 2048 hardware_tiers: arm-npu-8gb: diff --git a/tests/catalog/test_resolver_hailo.py b/tests/catalog/test_resolver_hailo.py new file mode 100644 index 000000000..15c7c441e --- /dev/null +++ b/tests/catalog/test_resolver_hailo.py @@ -0,0 +1,74 @@ +"""Hailo-10H end-to-end resolver coverage. + +Loads a real HEF model manifest from app-catalog and resolves it against a +hardware profile built via hardware_to_targets(), the same path the store +install dispatcher uses. Catches the class of bug where a manifest's +requires.backends entry has no targets, so no device can ever resolve it. +""" +from pathlib import Path + +import yaml + +from tinyagentos.catalog.resolver import DeviceCapability, ResolveErr, ResolveOk, resolve +from tinyagentos.cluster.capabilities import hardware_to_targets + +_MODELS_DIR = Path(__file__).resolve().parents[2] / "app-catalog" / "models" + + +def _load_manifest(model_id: str) -> dict: + path = _MODELS_DIR / model_id / "manifest.yaml" + return yaml.safe_load(path.read_text()) + + +def _pi5_hailo_hardware() -> dict: + return { + "cpu": {"arch": "aarch64"}, + "npu": {"type": "hailo10h", "tops": 40, "cores": 1}, + "ram_mb": 8192, + } + + +def _x86_cpu_only_hardware() -> dict: + return { + "cpu": {"arch": "x86_64"}, + "ram_mb": 16384, + } + + +class TestHailoManifestResolves: + def test_pi5_hailo_resolves_qwen25_1_5b_hef_to_hailo_ollama(self): + manifest = _load_manifest("qwen2.5-1.5b-instruct-hef") + targets = hardware_to_targets(_pi5_hailo_hardware()) + assert "hailo" in targets, ( + "Pi 5 + Hailo-10H hardware profile did not produce a 'hailo' " + f"catalog target: {targets!r}" + ) + device = DeviceCapability( + device_id="pi5-hailo", + targets=tuple(targets), + total_ram_mb=8192, + total_vram_mb=0, + free_disk_mb=50_000, + installed_backends=(), + ) + result = resolve(manifest, "a8w4", device) + assert isinstance(result, ResolveOk), ( + f"expected ResolveOk on a Hailo-10H device, got {result!r}" + ) + assert result.backend_id == "hailo-ollama" + + def test_cpu_only_x86_cannot_resolve_hailo_manifest(self): + manifest = _load_manifest("qwen2.5-1.5b-instruct-hef") + targets = hardware_to_targets(_x86_cpu_only_hardware()) + device = DeviceCapability( + device_id="x86-cpu-only", + targets=tuple(targets), + total_ram_mb=16384, + total_vram_mb=0, + free_disk_mb=50_000, + installed_backends=(), + ) + result = resolve(manifest, "a8w4", device) + assert isinstance(result, ResolveErr), ( + f"expected ResolveErr on a CPU-only x86 device, got {result!r}" + ) diff --git a/tinyagentos/cluster/capabilities.py b/tinyagentos/cluster/capabilities.py index 1ba28ce6a..e2d94c920 100644 --- a/tinyagentos/cluster/capabilities.py +++ b/tinyagentos/cluster/capabilities.py @@ -148,6 +148,8 @@ def hardware_to_targets(hardware: dict) -> list[str]: # NPU takes priority over GPU when both are present. if npu_type in ("rk3588", "rknpu"): targets.append("rockchip") + elif npu_type == "hailo10h": + targets.append("hailo") elif gpu_type == "apple": targets.append("apple-silicon") elif gpu_type == "nvidia" and gpu.get("cuda"): From 88cb74e6290fe709bb1b3ba68ffd998ac216aa7c Mon Sep 17 00:00:00 2001 From: jaylfc Date: Mon, 10 Aug 2026 06:32:41 +0000 Subject: [PATCH 12/56] fix(hailo-catalog): pin real sha256 digests, sizes, and Instruct homepages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sha256 was empty on all five HEF manifests, which silently skips integrity verification on download. Pulled the pinned per-model hef_h10h digests from hailo-ai/hailo_model_zoo_genai at tag v5.1.1 (the same release the download URLs point at) and spot-verified one (Qwen2-1.5B-Instruct.hef) by streaming the file and comparing sha256sum — exact match. size_mb was also stale on every entry; recomputed from the actual Content-Length of each dev-public.hailo.ai URL (bytes / 1048576, rounded), which corrected two entries significantly: qwen2.5-1.5b-instruct-hef 1679 -> 2250 and deepseek-r1-distill-qwen-1.5b-hef 2427 -> 2261. Updated the two matching "GB" labels for consistency. The other three shifted by only a few MB. llama3.2-3b-instruct-hef and qwen2.5-coder-1.5b-instruct-hef pointed their homepage at the base (non-Instruct) HuggingFace model card; both HEF variants are instruction-tuned, so point at the *-Instruct card instead. --- .../models/deepseek-r1-distill-qwen-1.5b-hef/manifest.yaml | 7 +++---- app-catalog/models/llama3.2-3b-instruct-hef/manifest.yaml | 7 +++---- app-catalog/models/qwen2-1.5b-instruct-hef/manifest.yaml | 5 ++--- app-catalog/models/qwen2.5-1.5b-instruct-hef/manifest.yaml | 7 +++---- .../models/qwen2.5-coder-1.5b-instruct-hef/manifest.yaml | 7 +++---- 5 files changed, 14 insertions(+), 19 deletions(-) diff --git a/app-catalog/models/deepseek-r1-distill-qwen-1.5b-hef/manifest.yaml b/app-catalog/models/deepseek-r1-distill-qwen-1.5b-hef/manifest.yaml index 8161b6fac..29edd4865 100644 --- a/app-catalog/models/deepseek-r1-distill-qwen-1.5b-hef/manifest.yaml +++ b/app-catalog/models/deepseek-r1-distill-qwen-1.5b-hef/manifest.yaml @@ -10,12 +10,11 @@ capabilities: - reasoning variants: - id: a8w4 - name: A8W4 HEF (2.4GB, NPU) + name: A8W4 HEF (2.2GB, NPU) format: hef - size_mb: 2427 + size_mb: 2261 download_url: https://dev-public.hailo.ai/v5.1.1/blob/DeepSeek-R1-Distill-Qwen-1.5B.hef - # sha256: fill in from the pinned HEF file before shipping to users - sha256: '' + sha256: 9c4506dda44d0a1730d939d4049a3cbf72d5179a88762ca551363db087adb38f requires: backends: - id: hailo-ollama diff --git a/app-catalog/models/llama3.2-3b-instruct-hef/manifest.yaml b/app-catalog/models/llama3.2-3b-instruct-hef/manifest.yaml index 5519e71fd..70b0e4bd1 100644 --- a/app-catalog/models/llama3.2-3b-instruct-hef/manifest.yaml +++ b/app-catalog/models/llama3.2-3b-instruct-hef/manifest.yaml @@ -3,7 +3,7 @@ name: Llama 3.2 3B Instruct (HEF) type: model version: 3.2.0 description: "Hailo-10H NPU-accelerated Llama 3.2 3B Instruct — runs on Raspberry Pi 5 + AI HAT+2 via hailo-ollama" -homepage: https://huggingface.co/meta-llama/Llama-3.2-3B +homepage: https://huggingface.co/meta-llama/Llama-3.2-3B-Instruct license: llama3.2 capabilities: - chat @@ -11,11 +11,10 @@ variants: - id: a8w4 name: A8W4 HEF (3.2GB, NPU) format: hef - size_mb: 3205 + size_mb: 3214 download_url: https://dev-public.hailo.ai/v5.1.1/blob/Llama-3_2-3B-Instruct.hef - # sha256: fill in from the pinned HEF file before shipping to users # Accuracy is under active optimization in the v5.1.1 Hailo Model Zoo release. - sha256: '' + sha256: 1129f5f8384e4e45c5890104dc4ec1aee77e800ce1484ddc3aa942399aada425 requires: backends: - id: hailo-ollama diff --git a/app-catalog/models/qwen2-1.5b-instruct-hef/manifest.yaml b/app-catalog/models/qwen2-1.5b-instruct-hef/manifest.yaml index 888cb4f3c..93c8f9dee 100644 --- a/app-catalog/models/qwen2-1.5b-instruct-hef/manifest.yaml +++ b/app-catalog/models/qwen2-1.5b-instruct-hef/manifest.yaml @@ -11,10 +11,9 @@ variants: - id: a8w4 name: A8W4 HEF (1.6GB, NPU) format: hef - size_mb: 1597 + size_mb: 1600 download_url: https://dev-public.hailo.ai/v5.1.1/blob/Qwen2-1.5B-Instruct.hef - # sha256: fill in from the pinned HEF file before shipping to users - sha256: '' + sha256: ab056548c60945cdf4fb30ca43fc7aeed2b9ffc751ad8d4c201dc4c4ab31e86a requires: backends: - id: hailo-ollama diff --git a/app-catalog/models/qwen2.5-1.5b-instruct-hef/manifest.yaml b/app-catalog/models/qwen2.5-1.5b-instruct-hef/manifest.yaml index a9dea3a6c..ffb533be1 100644 --- a/app-catalog/models/qwen2.5-1.5b-instruct-hef/manifest.yaml +++ b/app-catalog/models/qwen2.5-1.5b-instruct-hef/manifest.yaml @@ -10,12 +10,11 @@ capabilities: - tool-calling variants: - id: a8w4 - name: A8W4 HEF (1.7GB, NPU) + name: A8W4 HEF (2.2GB, NPU) format: hef - size_mb: 1679 + size_mb: 2250 download_url: https://dev-public.hailo.ai/v5.1.1/blob/Qwen2.5-1.5B-Instruct.hef - # sha256: fill in from the pinned HEF file before shipping to users - sha256: '' + sha256: 5310176848638505fbc28add04ba60c97abe345cdb0ec7e3b8ffaa4b0a8c65dd requires: backends: - id: hailo-ollama diff --git a/app-catalog/models/qwen2.5-coder-1.5b-instruct-hef/manifest.yaml b/app-catalog/models/qwen2.5-coder-1.5b-instruct-hef/manifest.yaml index 11e37c95d..58f3f7f1d 100644 --- a/app-catalog/models/qwen2.5-coder-1.5b-instruct-hef/manifest.yaml +++ b/app-catalog/models/qwen2.5-coder-1.5b-instruct-hef/manifest.yaml @@ -3,7 +3,7 @@ name: Qwen 2.5 Coder 1.5B Instruct (HEF) type: model version: 1.5.0 description: "Hailo-10H NPU-accelerated Qwen 2.5 Coder 1.5B Instruct — runs on Raspberry Pi 5 + AI HAT+2 via hailo-ollama" -homepage: https://huggingface.co/Qwen/Qwen2.5-Coder-1.5B +homepage: https://huggingface.co/Qwen/Qwen2.5-Coder-1.5B-Instruct license: Apache-2.0 capabilities: - chat @@ -13,10 +13,9 @@ variants: - id: a8w4 name: A8W4 HEF (1.7GB, NPU) format: hef - size_mb: 1679 + size_mb: 1675 download_url: https://dev-public.hailo.ai/v5.1.1/blob/Qwen2.5-Coder-1.5B-Instruct.hef - # sha256: fill in from the pinned HEF file before shipping to users - sha256: '' + sha256: 88aa7633ebe3385452430ae19f2b459b5a00791cab035576a3262a41ec1350f5 requires: backends: - id: hailo-ollama From 434e08d1b74fa8fc90718519e07fbfa400bc3898 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Mon, 10 Aug 2026 06:33:05 +0000 Subject: [PATCH 13/56] fix(hailo-catalog): rename llama3.2-3b-instruct-hef to match id convention Every other Llama 3.2 entry in the catalog uses a dashed llama-3.2- id (llama-3.2-1b, llama-3.2-3b). The new HEF manifest dropped the dash before the version, so rename the directory and the manifest's id field to match: llama3.2-3b-instruct-hef -> llama-3.2-3b-instruct-hef. --- .../manifest.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename app-catalog/models/{llama3.2-3b-instruct-hef => llama-3.2-3b-instruct-hef}/manifest.yaml (96%) diff --git a/app-catalog/models/llama3.2-3b-instruct-hef/manifest.yaml b/app-catalog/models/llama-3.2-3b-instruct-hef/manifest.yaml similarity index 96% rename from app-catalog/models/llama3.2-3b-instruct-hef/manifest.yaml rename to app-catalog/models/llama-3.2-3b-instruct-hef/manifest.yaml index 70b0e4bd1..7345a024f 100644 --- a/app-catalog/models/llama3.2-3b-instruct-hef/manifest.yaml +++ b/app-catalog/models/llama-3.2-3b-instruct-hef/manifest.yaml @@ -1,4 +1,4 @@ -id: llama3.2-3b-instruct-hef +id: llama-3.2-3b-instruct-hef name: Llama 3.2 3B Instruct (HEF) type: model version: 3.2.0 From 2cafd110123ed49e7efda0f2b1c5005176241fb9 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Mon, 10 Aug 2026 06:34:09 +0000 Subject: [PATCH 14/56] fix(models): recognize .hef files in the model-file scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _MODEL_FILE_SUFFIXES drove both the downloaded-models listing and the orphan-file scan in routes/models.py, but didn't know about .hef — so files pulled by the new Hailo-10H HEF manifests would silently never show up as local files or be considered for orphan cleanup. --- tinyagentos/routes/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tinyagentos/routes/models.py b/tinyagentos/routes/models.py index 5452c64f8..40970384a 100644 --- a/tinyagentos/routes/models.py +++ b/tinyagentos/routes/models.py @@ -41,7 +41,7 @@ class PullRequest(BaseModel): DEFAULT_MODELS_DIR = models_root() -_MODEL_FILE_SUFFIXES = (".gguf", ".rkllm", ".bin", ".safetensors", ".onnx") +_MODEL_FILE_SUFFIXES = (".gguf", ".rkllm", ".bin", ".safetensors", ".onnx", ".hef") def get_downloaded_models(models_dir: Path) -> list[dict]: From 6d05713164fae00f22c2f7a64c9bc5e0dafdb16c Mon Sep 17 00:00:00 2001 From: jaylfc Date: Mon, 10 Aug 2026 06:34:42 +0000 Subject: [PATCH 15/56] feat(projects): wire strike store into app lifespan and add unquarantine route #2333 added StrikeStore and ProjectTaskStore's strikes= param but never constructed the store or passed it in, so quarantine/close-time strike clearing was dead code and the strikes= param was inert. - Construct StrikeStore in create_app(), init/close it in the lifespan alongside the other project-scoped stores, attach it as app.state.task_strikes, and pass it into ProjectTaskStore(strikes=...). - Fix tinyagentos/projects/ids.py: StrikeStore.record_strike calls new_id("str") but "str" was never registered in ID_PREFIXES, so every strike record raised ValueError. Without this the feature never worked at all, wiring or not. - Surface strike_count + latest_strike on GET task detail. - Add a LEAD-only POST .../tasks/{id}/unquarantine route (mirrors the claimable curation gate), wired into the agent-token allowlist and docs/agent-coordination.md. - tests/conftest.py's client fixture bypasses the lifespan and manually inits every store it touches; task_strikes needed the same treatment or GET task detail 500s under that fixture. - Add tests/projects/test_strike_wiring.py exercising the real app lifespan (app.router.lifespan_context) rather than the bypass fixture, since the bypass would pass even with the wiring missing. --- changelog.d/2333-strike-quarantine-wiring.md | 5 + docs/agent-coordination.md | 3 + tests/conftest.py | 4 + tests/projects/test_strike_wiring.py | 131 +++++++++++++++++++ tinyagentos/app.py | 6 + tinyagentos/auth_middleware.py | 4 + tinyagentos/projects/ids.py | 2 +- tinyagentos/routes/projects.py | 40 ++++++ 8 files changed, 194 insertions(+), 1 deletion(-) create mode 100644 changelog.d/2333-strike-quarantine-wiring.md create mode 100644 tests/projects/test_strike_wiring.py diff --git a/changelog.d/2333-strike-quarantine-wiring.md b/changelog.d/2333-strike-quarantine-wiring.md new file mode 100644 index 000000000..43e1e759a --- /dev/null +++ b/changelog.d/2333-strike-quarantine-wiring.md @@ -0,0 +1,5 @@ +### Added + +- Quarantined task cards surface their strike count and latest strike on the + task-detail response, and a lead can un-quarantine a card via + `POST /api/projects/{pid}/tasks/{tid}/unquarantine`, clearing its strikes (#2333). diff --git a/docs/agent-coordination.md b/docs/agent-coordination.md index d0046783d..9e72a0ce4 100644 --- a/docs/agent-coordination.md +++ b/docs/agent-coordination.md @@ -229,6 +229,9 @@ The surface, by scope: `POST .../tasks/{id}/(claim|release|close|reopen)`, and `GET /api/projects/tasks/{id}/context`. This is read + lifecycle + comments only. Granting project_tasks also makes the agent a project member. + `POST .../tasks/{id}/unquarantine` is also reachable, but LEAD-only: the + route (`_authorize_project_lead`) refuses a plain project_tasks worker. + It returns a quarantined card to the open pool and clears its strikes. - **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. diff --git a/tests/conftest.py b/tests/conftest.py index 84f01d25b..6ded9823b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -356,6 +356,10 @@ async def client(app, tmp_data_dir): if receipt_store._db is not None: await receipt_store.close() await receipt_store.init() + task_strikes = app.state.task_strikes + if task_strikes._db is not None: + await task_strikes.close() + await task_strikes.init() project_task_store = app.state.project_task_store if project_task_store._db is not None: await project_task_store.close() diff --git a/tests/projects/test_strike_wiring.py b/tests/projects/test_strike_wiring.py new file mode 100644 index 000000000..838a74f9b --- /dev/null +++ b/tests/projects/test_strike_wiring.py @@ -0,0 +1,131 @@ +"""Wiring tests for the strike store (tsk-orqoif / #2333 follow-up). + +#2333 added StrikeStore and an optional ``strikes=`` param on ProjectTaskStore +but never constructed the store, attached it to app.state, or passed it into +ProjectTaskStore -- the param was inert. These tests exercise the REAL app +lifespan (mirroring tests/projects/test_routes_beads.py) rather than the +'client' fixture, because 'client' bypasses the lifespan entirely and would +pass even with the store never wired up. +""" +from __future__ import annotations + +import pytest +from httpx import ASGITransport, AsyncClient + + +def _auth_client(app): + """Return a session-cookie-authenticated AsyncClient for the given app.""" + app.state.auth.setup_user("admin", "Test Admin", "", "testpass") + record = app.state.auth.find_user("admin") + uid = record["id"] if record else "" + token = app.state.auth.create_session(user_id=uid, long_lived=True) + return AsyncClient( + transport=ASGITransport(app=app), + base_url="http://test", + cookies={"taos_session": token}, + ) + + +async def _make_project_and_task(c, slug: str) -> tuple[str, str]: + r = await c.post("/api/projects", json={"name": "Demo", "slug": slug}) + assert r.status_code == 200, r.text + project_id = r.json()["id"] + r = await c.post(f"/api/projects/{project_id}/tasks", json={"title": "T1"}) + assert r.status_code == 200, r.text + return project_id, r.json()["id"] + + +@pytest.mark.asyncio +async def test_app_state_has_task_strikes_store(app): + """StrikeStore must be attached to app.state and initialised by the lifespan.""" + async with app.router.lifespan_context(app): + strikes = app.state.task_strikes + assert strikes is not None + # Prove it is actually usable (init() ran), not just non-None. + count = await strikes.record_strike("tsk-fake", "verify", log_tail="boom") + assert count == 1 + assert await strikes.count_strikes("tsk-fake") == 1 + + +@pytest.mark.asyncio +async def test_close_task_clears_strikes_via_taskstore_wiring(app): + """ProjectTaskStore must have actually received the strike store: closing + a task with recorded strikes should clear them (task_store.py's + close_task -> self._strikes.clear_strikes). This fails if the + strikes=strike_store constructor arg was never wired in app.py.""" + async with app.router.lifespan_context(app): + async with _auth_client(app) as c: + _project_id, task_id = await _make_project_and_task(c, "strike-close") + + strikes = app.state.task_strikes + await strikes.record_strike(task_id, "verify", log_tail="fail 1") + await strikes.record_strike(task_id, "verify", log_tail="fail 2") + assert await strikes.count_strikes(task_id) == 2 + + r = await c.post( + f"/api/projects/{_project_id}/tasks/{task_id}/close", + json={"closed_by": "tester"}, + ) + assert r.status_code == 200, r.text + + assert await strikes.count_strikes(task_id) == 0 + + +@pytest.mark.asyncio +async def test_get_task_surfaces_strike_count_and_latest(app): + """Task-detail response must surface strike_count + latest_strike.""" + async with app.router.lifespan_context(app): + async with _auth_client(app) as c: + project_id, task_id = await _make_project_and_task(c, "strike-surface") + + strikes = app.state.task_strikes + await strikes.record_strike(task_id, "verify", log_tail="first") + await strikes.record_strike(task_id, "verify", log_tail="second") + + r = await c.get(f"/api/projects/{project_id}/tasks/{task_id}") + assert r.status_code == 200, r.text + body = r.json() + assert body["strike_count"] == 2 + assert body["latest_strike"] is not None + assert body["latest_strike"]["log_tail"] == "second" + + +@pytest.mark.asyncio +async def test_unquarantine_route_lead_success_and_noauth_failure(app): + """The unquarantine route must work through the real HTTP layer with + proper (lead) auth, clear strikes, and be refused 401 without auth.""" + async with app.router.lifespan_context(app): + async with _auth_client(app) as c: + project_id, task_id = await _make_project_and_task(c, "strike-unq") + + task_store = app.state.project_task_store + strikes = app.state.task_strikes + await strikes.record_strike(task_id, "verify", log_tail="strike 3") + ok = await task_store.quarantine_task(task_id, "system") + assert ok is True + + unauth = AsyncClient(transport=ASGITransport(app=app), base_url="http://test") + try: + r = await unauth.post( + f"/api/projects/{project_id}/tasks/{task_id}/unquarantine" + ) + finally: + await unauth.aclose() + # No session cookie and no Authorization header at all: the global + # auth middleware gate refuses the request before it ever reaches + # the route (same as every other task route with zero credentials). + assert r.status_code == 401 + + # Task must still be quarantined -- the unauthenticated call above + # must not have mutated it. + still_quarantined = await task_store.get_task(task_id) + assert still_quarantined["status"] == "quarantined" + + r = await c.post( + f"/api/projects/{project_id}/tasks/{task_id}/unquarantine" + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["status"] == "open" + + assert await strikes.count_strikes(task_id) == 0 diff --git a/tinyagentos/app.py b/tinyagentos/app.py index 213759d9f..c088605ab 100644 --- a/tinyagentos/app.py +++ b/tinyagentos/app.py @@ -412,11 +412,14 @@ async def _probe_backend(backend: dict) -> dict: board_audit_store = BoardAuditLog(data_dir / "board_audit.db") from tinyagentos.receipt_store import ReceiptStore receipt_store = ReceiptStore(data_dir / "receipts.db") + from tinyagentos.projects.strike_store import StrikeStore + strike_store = StrikeStore(data_dir / "task_strikes.db") project_task_store = ProjectTaskStore( data_dir / "projects.db", broker=project_event_broker, audit=board_audit_store, project_store=project_store, + strikes=strike_store, ) project_element_store = ProjectElementStore(data_dir / "projects.db") from tinyagentos.projects.routines_store import RoutineStore @@ -586,6 +589,7 @@ async def lifespan(app: FastAPI): await project_invite_store.init() await board_audit_store.init() await receipt_store.init() + await strike_store.init() await project_task_store.init() await project_element_store.init() await routine_store.init() @@ -1458,6 +1462,7 @@ async def _web_push_sender(row: dict) -> None: await doc_review_store.close() await project_notes_store.close() await project_invite_store.close() + await strike_store.close() await project_task_store.close() await project_element_store.close() await routine_store.close() @@ -1614,6 +1619,7 @@ async def dispatch(self, request, call_next): app.state.project_invites = project_invite_store app.state.board_audit = board_audit_store app.state.receipt_store = receipt_store + app.state.task_strikes = strike_store app.state.project_task_store = project_task_store app.state.project_element_store = project_element_store app.state.routine_store = routine_store diff --git a/tinyagentos/auth_middleware.py b/tinyagentos/auth_middleware.py index ff47c32cb..778bb73a5 100644 --- a/tinyagentos/auth_middleware.py +++ b/tinyagentos/auth_middleware.py @@ -86,6 +86,10 @@ # a plain project_tasks worker is refused. Toggles only the "claimable" # label, so it does not widen the scope into free field edits (cf. PATCH). ("POST", re.compile(rf"^/api/projects/{_SEG}/tasks/{_SEG}/claimable$")), + # Un-quarantine curation: same LEAD-only gate as claimable above + # (_authorize_project_lead). Returns a quarantined card to the open pool + # and clears its strikes (see StrikeStore / unquarantine_task). + ("POST", re.compile(rf"^/api/projects/{_SEG}/tasks/{_SEG}/unquarantine$")), ) # Project doc-review stamp store routes an agent may reach with its own registry diff --git a/tinyagentos/projects/ids.py b/tinyagentos/projects/ids.py index 71b6827e7..30465b96e 100644 --- a/tinyagentos/projects/ids.py +++ b/tinyagentos/projects/ids.py @@ -1,7 +1,7 @@ from __future__ import annotations import secrets -ID_PREFIXES = ("prj", "tsk", "cmt", "rel", "cve", "dec", "doc", "ent", "lst", "rev", "cs", "rtn", "elm", "note") +ID_PREFIXES = ("prj", "tsk", "cmt", "rel", "cve", "dec", "doc", "ent", "lst", "rev", "cs", "rtn", "elm", "note", "str") _ALPHABET = "abcdefghijklmnopqrstuvwxyz234567" diff --git a/tinyagentos/routes/projects.py b/tinyagentos/routes/projects.py index 2bf46634b..1fb79e134 100644 --- a/tinyagentos/routes/projects.py +++ b/tinyagentos/routes/projects.py @@ -766,6 +766,10 @@ async def get_task( t = await store.get_task(task_id) if t is None or t["project_id"] != project_id: return JSONResponse({"error": "not found"}, status_code=404) + strikes = getattr(request.app.state, "task_strikes", None) + if strikes is not None: + t["strike_count"] = await strikes.count_strikes(task_id) + t["latest_strike"] = await strikes.latest(task_id) return t @@ -1043,6 +1047,42 @@ async def reopen_task( return await store.get_task(task_id) +@router.post("/api/projects/{project_id}/tasks/{task_id}/unquarantine") +async def unquarantine_task( + project_id: str, + task_id: str, + request: Request, +): + """Return a quarantined card to the open pool and clear its strikes. + + LEAD-only curation, mirroring mark_task_claimable: only a project LEAD + (session owner/admin, or the lead agent's ``project_tasks`` token) may + retry a quarantined card -- a plain project_tasks worker cannot self + un-quarantine. See ProjectTaskStore.unquarantine_task for the strike-clear + behaviour. + """ + pstore = request.app.state.project_store + auth = await _authorize_project_lead(request, pstore, project_id) + if isinstance(auth, JSONResponse): + return auth + actor_id, _is_agent, _project = auth + store = request.app.state.project_task_store + existing = await store.get_task(task_id) + if existing is None or existing["project_id"] != project_id: + return JSONResponse({"error": "not found"}, status_code=404) + ok = await store.unquarantine_task(task_id, actor_id) + if not ok: + return JSONResponse({"error": "task is not quarantined"}, status_code=409) + _beads_mark_dirty(request, project_id) + await pstore.log_activity(project_id, actor_id, "task.unquarantined", {"task_id": task_id}) + notifs = getattr(request.app.state, "notifications", None) + if notifs is not None: + await notifs.emit_event( + "task.unquarantined", "Task unquarantined", f"{task_id} unquarantined by {actor_id}" + ) + return await store.get_task(task_id) + + @router.get("/api/projects/{project_id}/audit") async def project_audit_feed( project_id: str, From 6a72e83cacd09aa1ea8eb48bb5d7cf9a8b9c5c5c Mon Sep 17 00:00:00 2001 From: jaylfc Date: Mon, 10 Aug 2026 06:35:29 +0000 Subject: [PATCH 16/56] docs(readme): refresh catalog counts and mention Hailo-10H HEF variants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README's catalog counts were stale at 113 in four places (dev had already moved to 115 before this PR, and this PR's five HEF manifests bring it to 120 — verified by counting app-catalog/models/*/manifest.yaml directories in-tree). Also "verified against HuggingFace" is no longer true now that the Hailo HEF manifests point at dev-public.hailo.ai, so reword to "its upstream host", and call out the new Hailo-10H HEF variants alongside the existing RK3588 NPU mention. --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 32bcee8e1..128f9a7c0 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ Self-hosted AI agent platform that runs on whatever hardware you have. An old laptop, a Raspberry Pi, a gaming PC, an SBC gathering dust, or all of them at once. taOS turns your spare hardware into a distributed AI compute cluster. -A full web desktop environment with 40 bundled apps, 109 catalog apps, 47 MCP plugins, 17 agent frameworks, a curated local model catalog of 113 manifests covering LLMs, vision, embeddings, audio, and image generation (including RK3588 NPU variants via c01zaut/happyme531), plus 167k+ searchable models from HuggingFace, agent deployment, training, image/video/audio generation, and full system monitoring, all from a single web dashboard. Supports Apple Silicon (MLX), NVIDIA, AMD, Rockchip NPU, Raspberry Pi, Android phones, and more. +A full web desktop environment with 40 bundled apps, 109 catalog apps, 47 MCP plugins, 17 agent frameworks, a curated local model catalog of 120 manifests covering LLMs, vision, embeddings, audio, and image generation (including RK3588 NPU variants via c01zaut/happyme531 and Hailo-10H HEF variants via hailo-ollama), plus 167k+ searchable models from HuggingFace, agent deployment, training, image/video/audio generation, and full system monitoring, all from a single web dashboard. Supports Apple Silicon (MLX), NVIDIA, AMD, Rockchip NPU, Raspberry Pi, Android phones, and more. **Framework-agnostic by design.** taOS owns everything that matters: your agent's memory, files, communication channels, model access, and configuration. The agent framework is just a replaceable execution engine. Switch from SmolAgents to LangChain to OpenClaw and your agent keeps its entire history, all its Telegram/Discord/Slack connections, its trained LoRA adapters, its files, and its API keys. No migration, no data loss, no reconfiguration. This is possible because taOS manages the full agent lifecycle outside the framework. @@ -215,7 +215,7 @@ lets the scheduler route work only to backends that are genuinely ready. See [docs/design/resource-scheduler.md](docs/design/resource-scheduler.md). ### Local Model Catalog + Live Model Browser -A curated catalog of 113 vetted model manifests ships in-tree, every download URL is verified against HuggingFace, covering LLMs (Qwen3, Qwen2.5, Llama 3.1/3.3, Gemma 2/3, Phi-4, Mistral, Mixtral, DeepSeek, Granite, Command-R), vision models (Qwen2.5-VL, MiniCPM-V 2.6, Moondream2, Florence-2, LLaVA), embeddings (nomic, bge, mxbai, snowflake-arctic), rerankers (bge-reranker-v2, qwen3-reranker), speech (Whisper tiny→large-v3-turbo, Kokoro TTS, Piper, Parakeet), image generation (SD 1.5 LCM, Dreamshaper 8 LCM, SDXL Turbo/Lightning, Flux schnell/dev, SD3.5, PixArt-Σ, Playground v2.5, Kolors, AuraFlow), and image tools (RMBG-1.4, BiRefNet, Real-ESRGAN, 4x-UltraSharp, GFPGAN, CodeFormer, ControlNet canny/depth/pose). **RK3588 NPU variants** are included via c01zaut (Qwen2.5 1.5B→14B RKLLM) and happyme531 (LCM Dreamshaper SD as multi-file RKNN). The live Model Browser also searches 167k+ GGUF models from HuggingFace and the Ollama library. Hardware-filtered compatibility indicators show what runs on your device (green/yellow/red). +A curated catalog of 120 vetted model manifests ships in-tree, every download URL is verified against its upstream host, covering LLMs (Qwen3, Qwen2.5, Llama 3.1/3.3, Gemma 2/3, Phi-4, Mistral, Mixtral, DeepSeek, Granite, Command-R), vision models (Qwen2.5-VL, MiniCPM-V 2.6, Moondream2, Florence-2, LLaVA), embeddings (nomic, bge, mxbai, snowflake-arctic), rerankers (bge-reranker-v2, qwen3-reranker), speech (Whisper tiny→large-v3-turbo, Kokoro TTS, Piper, Parakeet), image generation (SD 1.5 LCM, Dreamshaper 8 LCM, SDXL Turbo/Lightning, Flux schnell/dev, SD3.5, PixArt-Σ, Playground v2.5, Kolors, AuraFlow), and image tools (RMBG-1.4, BiRefNet, Real-ESRGAN, 4x-UltraSharp, GFPGAN, CodeFormer, ControlNet canny/depth/pose). **RK3588 NPU variants** are included via c01zaut (Qwen2.5 1.5B→14B RKLLM) and happyme531 (LCM Dreamshaper SD as multi-file RKNN). **Hailo-10H NPU variants** (Raspberry Pi 5 + AI HAT+2, served by hailo-ollama) ship as `.hef` manifests: DeepSeek-R1-Distill-Qwen 1.5B, Llama 3.2 3B, Qwen2 1.5B, Qwen2.5 1.5B, and Qwen2.5 Coder 1.5B. The live Model Browser also searches 167k+ GGUF models from HuggingFace and the Ollama library. Hardware-filtered compatibility indicators show what runs on your device (green/yellow/red). ### Agent Templates (1,467 Templates) Pick from 1,467 agent templates, 12 built-in plus 196 from awesome-openclaw-agents and 1,259 from the System Prompt Library, and deploy in one click. Browse by category (28 categories), filter by source, or search. Each template includes a system prompt, recommended framework, model, and resource limits. All templates vendored locally so nothing depends on external services. @@ -384,7 +384,7 @@ Search across agents, apps, messages, and shared folders from a single endpoint. |----------|------| | **Agent Frameworks (17)** | SmolAgents, PocketFlow, OpenClaw, nanoclaw, PicoClaw, ZeroClaw, MicroClaw, IronClaw, NullClaw, Moltis, Hermes, Agent Zero, OpenAI Agents SDK, Langroid, ShibaClaw, DeerFlow, OpenCrabs (beta) | | **Streaming Apps (13)** | Blender, LibreOffice, Code Server, GIMP, Krita, FreeCAD, Obsidian, Excalidraw, JupyterLab, Grafana, n8n, Terminal, Neko Browser | -| **LLM Models** | 113-manifest local catalog: Qwen3 0.6B-32B, Qwen2.5 0.5B-72B (+ RKLLM 1.5B-14B for RK3588), Llama 3.1/3.2/3.3, Gemma 2/3, Phi-3.5/4/4-mini, Mistral/Nemo/Mixtral, DeepSeek, Granite, Command-R, SmolLM2, TinyLlama, plus 167k+ searchable from HuggingFace | +| **LLM Models** | 120-manifest local catalog: Qwen3 0.6B-32B, Qwen2.5 0.5B-72B (+ RKLLM 1.5B-14B for RK3588), Llama 3.1/3.2/3.3, Gemma 2/3, Phi-3.5/4/4-mini, Mistral/Nemo/Mixtral, DeepSeek, Granite, Command-R, SmolLM2, TinyLlama, plus 167k+ searchable from HuggingFace | | **Vision Models** | Qwen2-VL, Qwen2.5-VL, MiniCPM-V 2.6, Moondream2, Florence-2, LLaVA 1.6 / LLaVA-Phi-3 | | **Embeddings / Rerankers** | nomic-embed-text-v1.5, bge-large/small/m3, mxbai-embed-large, snowflake-arctic-embed, qwen3-embedding/reranker, bge-reranker-v2-m3 | | **Audio Models** | Whisper tiny→large-v3-turbo, Kokoro TTS, Piper voices, Parakeet TDT | @@ -741,7 +741,7 @@ CI runs automatically on every push (Python 3.12 and 3.13 on every PR; Python 3. - [x] 47 MCP server plugins in app catalog - [x] Desktop notifications (toast stack + notification centre) - [x] Widget system (Clock, Agent Status, Notes, System Stats, Weather) -- [x] Curated local model catalog, 113 manifests, all download URLs verified against HuggingFace +- [x] Curated local model catalog, 120 manifests, all download URLs verified against their upstream host - [x] Activity monitor app, rktop-inspired per-core CPU/NPU/thermal/GPU/process stats - [x] Loaded Models panel in Model Browser, shows running models, purpose, and VRAM/RAM usage - [x] iOS PWA pill bar, safe-area-aware bottom nav with back / home / card-switcher / notifications From fa9d8fee2cbf3d21d75e2490fc9e81d350e0cf5d Mon Sep 17 00:00:00 2001 From: jaylfc Date: Mon, 10 Aug 2026 06:38:18 +0000 Subject: [PATCH 17/56] docs(changelog): add fragment for the Hailo-10H HEF catalog fixes routes/models.py now recognizes .hef files, which is a user-visible behaviour change and trips the doc-gate's user-visible-changelog rule (on_modify on tinyagentos/routes/*.py). Add the changelog.d fragment per docs/changelog-fragments.md instead of editing CHANGELOG.md directly. --- changelog.d/2338-hailo-hef-catalog-fixes.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 changelog.d/2338-hailo-hef-catalog-fixes.md diff --git a/changelog.d/2338-hailo-hef-catalog-fixes.md b/changelog.d/2338-hailo-hef-catalog-fixes.md new file mode 100644 index 000000000..d2c1e20c2 --- /dev/null +++ b/changelog.d/2338-hailo-hef-catalog-fixes.md @@ -0,0 +1,7 @@ +### Added + +- **Hailo-10H HEF model catalog**: five NPU-accelerated model manifests + (DeepSeek-R1-Distill-Qwen 1.5B, Llama 3.2 3B, Qwen2 1.5B, Qwen2.5 1.5B, + Qwen2.5 Coder 1.5B) now resolve and install via `hailo-ollama` on + Raspberry Pi 5 + AI HAT+2, and downloaded `.hef` files show up in the + local-files and orphan scans (#2338). From c6173c49041dbaf040f6d21e09e0ebe87a36be9e Mon Sep 17 00:00:00 2001 From: jaylfc Date: Mon, 10 Aug 2026 06:39:16 +0000 Subject: [PATCH 18/56] store-wiring gate: AST wiring check, R/C rename handling, waiver cleanup --- scripts/check_store_wiring.py | 87 ++++++++++++++++++++++++++++---- tests/test_check_store_wiring.py | 62 +++++++++++++++++++++++ 2 files changed, 140 insertions(+), 9 deletions(-) diff --git a/scripts/check_store_wiring.py b/scripts/check_store_wiring.py index c6d4cdc24..649443633 100644 --- a/scripts/check_store_wiring.py +++ b/scripts/check_store_wiring.py @@ -40,6 +40,7 @@ class Violation: class_name: str file_path: str + reason: str = "" def _run_git(args: list[str], repo_root: Path) -> str: @@ -96,6 +97,71 @@ def _class_def_in_added_lines( return bool(pattern.search(diff)) +def _is_wired_ast(app_py_content: str, class_name: str) -> bool: + """Return True if class_name is instantiated and assigned to app.state. + + Handles: + app.state.X = ClassName(...) + x = ClassName(...); app.state.X = x + x = ClassName(...); y = x; app.state.Y = y + """ + try: + tree = ast.parse(app_py_content) + except SyntaxError: + return False + + instance_vars: set[str] = set() + + for node in ast.walk(tree): + if not isinstance(node, ast.Assign): + continue + if isinstance(node.value, ast.Call): + func = node.value.func + if isinstance(func, ast.Name) and func.id == class_name: + for target in node.targets: + if isinstance(target, ast.Name): + instance_vars.add(target.id) + + changed = True + while changed: + changed = False + for node in ast.walk(tree): + if isinstance(node, ast.Assign): + if isinstance(node.value, ast.Name) and node.value.id in instance_vars: + for target in node.targets: + if isinstance(target, ast.Name) and target.id not in instance_vars: + instance_vars.add(target.id) + changed = True + + for node in ast.walk(tree): + if not isinstance(node, ast.Assign): + continue + for target in node.targets: + if not isinstance(target, ast.Attribute): + continue + if not (isinstance(target.value, ast.Name) and target.value.id == "app" and target.attr == "state"): + continue + if isinstance(node.value, ast.Call): + func = node.value.func + if isinstance(func, ast.Name) and func.id == class_name: + return True + if isinstance(node.value, ast.Name) and node.value.id in instance_vars: + return True + + return False + + +def _is_wired_in_app_py(app_py_content: str, class_name: str) -> tuple[bool, str]: + ast_ok = _is_wired_ast(app_py_content, class_name) + if ast_ok: + return True, "AST" + code_only = re.sub(r"#.*", "", app_py_content) + name_ok = bool(re.search(rf"\b{re.escape(class_name)}\b", code_only)) + if name_ok: + return True, "name-level-fallback" + return False, "unwired" + + def build_class_hierarchy(repo_root: Path) -> dict[str, set[str]]: """Build a map of class_name -> set of direct base class names.""" classes: dict[str, set[str]] = {} @@ -198,7 +264,7 @@ def check_store_wiring( continue if status.startswith("D"): continue - if not (status.startswith("A") or status.startswith("M")): + if not (status.startswith("A") or status.startswith("M") or status.startswith("R") or status.startswith("C")): continue abs_path = repo_root / file_path @@ -212,7 +278,7 @@ def check_store_wiring( for class_name in sorted(store_classes): is_new = False - if status.startswith("A"): + if status.startswith("A") or status.startswith("R") or status.startswith("C"): is_new = True elif status.startswith("M"): is_new = _class_def_in_added_lines( @@ -223,14 +289,17 @@ def check_store_wiring( continue if class_name in waived: - waived.add(class_name) continue - if not re.search(rf"\b{re.escape(class_name)}\b", app_py_content): - violations.append(Violation( - class_name=class_name, - file_path=file_path, - )) + wired, how = _is_wired_in_app_py(app_py_content, class_name) + if wired: + continue + + violations.append(Violation( + class_name=class_name, + file_path=file_path, + reason=how, + )) return violations, waived @@ -262,7 +331,7 @@ def main(argv: list[str] | None = None) -> int: f"so an unwired store is unreachable:" ) for v in violations: - print(f" - {v.class_name} in {v.file_path}") + print(f" - {v.class_name} in {v.file_path} ({v.reason})") return 1 print("store-wiring-guard: clean") diff --git a/tests/test_check_store_wiring.py b/tests/test_check_store_wiring.py index dcb681ce4..ad728f1c6 100644 --- a/tests/test_check_store_wiring.py +++ b/tests/test_check_store_wiring.py @@ -385,6 +385,68 @@ def test_transitive_subclass_flagged(self, tmp_path: Path): assert names == {"ParentStore", "ChildStore"} assert waived == set() + def test_comment_only_mention_fails_ast(self, tmp_path: Path): + repo = tmp_path / "repo" + base_tip = _setup_base_repo(repo) + + _branch(repo, "pr") + _checkout(repo, "pr") + _commit( + repo, "tinyagentos/comment_store.py", + "from tinyagentos.base_store import BaseStore\n" + "\n" + "class CommentStore(BaseStore):\n" + " SCHEMA = ''\n" + " MIGRATIONS = []\n", + "feat: add CommentStore", + ) + _commit( + repo, "tinyagentos/app.py", + "from tinyagentos.base_store import BaseStore\n" + "from tinyagentos.metrics_store import MetricsStore\n\n" + "# CommentStore is not wired here\n" + "metrics_store = MetricsStore('/tmp/metrics.db')\n\n" + "async def lifespan(app):\n" + " await metrics_store.init()\n" + " app.state.metrics = metrics_store\n", + "feat: mention CommentStore in comment", + ) + _checkout(repo, "main") + _git(repo, "merge", "pr", "--no-edit") + + violations, waived = csw.check_store_wiring(base_tip, repo) + + assert len(violations) == 1 + assert violations[0].class_name == "CommentStore" + assert violations[0].file_path == "tinyagentos/comment_store.py" + assert violations[0].reason == "unwired" + + def test_unwired_store_in_renamed_file_fails(self, tmp_path: Path): + repo = tmp_path / "repo" + base_tip = _setup_base_repo(repo) + + _branch(repo, "pr") + _checkout(repo, "pr") + _git(repo, "mv", "tinyagentos/metrics_store.py", "tinyagentos/metrics_v2_store.py") + _write( + repo, "tinyagentos/metrics_v2_store.py", + "from tinyagentos.base_store import BaseStore\n" + "\n" + "class MetricsV2Store(BaseStore):\n" + " SCHEMA = 'CREATE TABLE IF NOT EXISTS metrics_v2 (id INTEGER PRIMARY KEY);'\n" + " MIGRATIONS = []\n", + ) + _git(repo, "add", "tinyagentos/metrics_v2_store.py") + _git(repo, "commit", "-m", "feat: rename and add MetricsV2Store") + _checkout(repo, "main") + _git(repo, "merge", "pr", "--no-edit") + + violations, waived = csw.check_store_wiring(base_tip, repo) + + assert len(violations) == 1 + assert violations[0].class_name == "MetricsV2Store" + assert violations[0].file_path == "tinyagentos/metrics_v2_store.py" + def test_new_class_added_to_existing_file_fails(self, tmp_path: Path): repo = tmp_path / "repo" base_tip = _setup_base_repo(repo) From c2bd8931c362f5b689458f489be9d2b8befb3731 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Mon, 10 Aug 2026 06:49:47 +0000 Subject: [PATCH 19/56] feat(agent-loop): wire AgentLoop as the per-agent serialization owner AgentChatRouter drives OpenClaw ACP turns through one AgentLoop per agent (replacing the per-agent asyncio.Lock): the turn-holder drives its turn, then iteratively drives every message queued at the safe point, each with its own trace id. reach_safe_point runs in a finally so a raising turn can never wedge the loop in WORKING. The desktop taOS agent chat endpoint serializes on app.state.taos_agent_loop, fixing a race where two concurrent POSTs shared the opencode session with no serialization. A concurrent request gets a queued-notice NDJSON frame; the turn-holder surfaces queued message contents into its stream tail before the final done frame (redrive out of scope). New GET /api/taos-agent/status returns the desktop loop's status scoped to state / current_turn_id / queued_count / subagents [{id, task, state, started_at}] with subagent result/error stripped. Refs: tsk-icpt4i --- changelog.d/tsk-icpt4i-agent-loop-wiring.md | 12 ++ changelog.d/tsk-rl2lfb-agent-loop.md | 6 +- docs/design/agent-loop-subagents.md | 39 ++-- tests/test_agent_chat_router.py | 107 +++++++++++ tests/test_taos_agent_chat.py | 190 ++++++++++++++++++++ tinyagentos/agent_chat_router.py | 55 ++++-- tinyagentos/routes/taos_agent.py | 88 ++++++++- 7 files changed, 472 insertions(+), 25 deletions(-) create mode 100644 changelog.d/tsk-icpt4i-agent-loop-wiring.md diff --git a/changelog.d/tsk-icpt4i-agent-loop-wiring.md b/changelog.d/tsk-icpt4i-agent-loop-wiring.md new file mode 100644 index 000000000..30d57e50c --- /dev/null +++ b/changelog.d/tsk-icpt4i-agent-loop-wiring.md @@ -0,0 +1,12 @@ +### Changed + +- **Agent loop wiring**: `AgentLoop` is now the single per-agent serialization + owner. `AgentChatRouter` drives OpenClaw ACP turns through a per-agent + `AgentLoop` (replacing the per-agent lock) and the turn-holder drives + messages queued mid-turn at its safe point. The desktop taOS agent chat + endpoint serializes on one `AgentLoop` too — fixing a race where two + concurrent POSTs shared the opencode session with no serialization — + queueing concurrent messages and surfacing them in the turn-holder's stream + tail. New `GET /api/taos-agent/status` endpoint returns the desktop loop's + status scoped to state / current turn / queue depth / subagent descriptors + (subagent result/error payloads stay server-side) (#tsk-icpt4i). diff --git a/changelog.d/tsk-rl2lfb-agent-loop.md b/changelog.d/tsk-rl2lfb-agent-loop.md index bc8115a2f..feeca9733 100644 --- a/changelog.d/tsk-rl2lfb-agent-loop.md +++ b/changelog.d/tsk-rl2lfb-agent-loop.md @@ -1,6 +1,6 @@ ### Added - **Agent loop infrastructure**: new `tinyagentos.agent_loop.AgentLoop` library - for subagent delegation and safe-point message queuing. This is standalone - infrastructure, not yet wired into the taOS chat agent or routes - (#tsk-rl2lfb). + for subagent delegation and safe-point message queuing. Landed as + standalone infrastructure; wired into the chat router and taOS agent + routes in #tsk-icpt4i (#tsk-rl2lfb). diff --git a/docs/design/agent-loop-subagents.md b/docs/design/agent-loop-subagents.md index 9e6948a0e..c87f317cd 100644 --- a/docs/design/agent-loop-subagents.md +++ b/docs/design/agent-loop-subagents.md @@ -75,16 +75,35 @@ SAFE_POINT --(immediate)--> IDLE ## Integration points -> **Not yet integrated**: `AgentLoop` is standalone library infrastructure. -> It is not yet wired into the taOS chat agent routes. Routing integration -> will be handled in a separate design card once the `AgentChatRouter`-lock -> design decision is made. - -- The taOS chat endpoint (`routes/taos_agent.py`) can use `AgentLoop` to wrap - `opencode_runtime.drive_turn`: start a turn, spawn a subagent for long tool - calls, and drain the queue at the end of the stream. -- The `AgentChatRouter._run_acp_turn` path can delegate to a subagent via - `openclaw_acp_runtime.drive_turn` when a turn is expected to be long. +`AgentLoop` is wired in as the single per-agent serialization owner +(tsk-icpt4i): + +- **`AgentChatRouter._run_acp_turn`** (`tinyagentos/agent_chat_router.py`) + keeps one `AgentLoop` per agent slug (replacing the previous per-agent + `asyncio.Lock`). The turn-holder drives its turn, then iteratively drains + the safe-point queue, driving each queued message as its own turn with its + original trace id. A caller whose message returns `QUEUED` exits + immediately — the turn-holder drives it. `reach_safe_point` runs in a + `finally` so a raising turn can never wedge the loop in `WORKING`. +- **`POST /api/taos-agent/chat`** (`routes/taos_agent.py`) serializes the + desktop taOS agent on a single `AgentLoop` held on + `app.state.taos_agent_loop` (previously two concurrent POSTs raced on the + shared opencode session with no serialization). A concurrent request gets a + one-frame NDJSON stream saying its message is queued; the turn-holder + surfaces queued message contents into its own stream's tail at the safe + point, before the final `{"done": true}`. +- **`GET /api/taos-agent/status`** returns the desktop loop's status scoped + to `state` / `current_turn_id` / `queued_count` / `subagents` as + `[{id, task, state, started_at}]` — subagent `result` / `error` payloads + stay server-side. + +Still not integrated: + +- Router-side subagent spawning (`spawn_subagent` is not called from + `_run_acp_turn`). +- Desktop-endpoint redrive: queued messages are surfaced into the + turn-holder's stream, not re-driven as their own turns. +- Router per-agent loops are not exposed via any status endpoint. ## Testing diff --git a/tests/test_agent_chat_router.py b/tests/test_agent_chat_router.py index d2d3885f8..2cac73770 100644 --- a/tests/test_agent_chat_router.py +++ b/tests/test_agent_chat_router.py @@ -756,3 +756,110 @@ async def test_manual_non_lead_branch_correct_for_worker(): manual = enqueued["context"][0]["content"] assert "You are NOT a lead" in manual assert "You ARE designated lead" not in manual + + +# --------------------------------------------------------------------------- +# AgentLoop serialization ownership (tsk-icpt4i) +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_run_acp_turn_same_agent_serializes_and_drives_both(monkeypatch): + """Two concurrent turns for the SAME agent never overlap, and BOTH + messages get driven (the queued one via the safe-point drain) with their + own trace ids preserved.""" + import tinyagentos.openclaw_acp_runtime as rt + + active = 0 + max_concurrent = 0 + driven: list[tuple[str, str, str]] = [] + + async def fake_drive_turn(*, slug, text, trace_id, record_reply): + nonlocal active, max_concurrent + active += 1 + max_concurrent = max(max_concurrent, active) + await asyncio.sleep(0.02) + driven.append((slug, text, trace_id)) + active -= 1 + + monkeypatch.setattr(rt, "drive_turn", fake_drive_turn) + + router = AgentChatRouter(MagicMock()) + await asyncio.gather( + router._run_acp_turn("a1", "m1", "t1", None), + router._run_acp_turn("a1", "m2", "t2", None), + ) + assert max_concurrent == 1 + assert sorted(driven) == [("a1", "m1", "t1"), ("a1", "m2", "t2")] + + +@pytest.mark.asyncio +async def test_run_acp_turn_different_agents_run_concurrently(monkeypatch): + """Turns for DIFFERENT agents are not serialized against each other.""" + import tinyagentos.openclaw_acp_runtime as rt + + active = 0 + max_concurrent = 0 + + async def fake_drive_turn(*, slug, text, trace_id, record_reply): + nonlocal active, max_concurrent + active += 1 + max_concurrent = max(max_concurrent, active) + await asyncio.sleep(0.02) + active -= 1 + + monkeypatch.setattr(rt, "drive_turn", fake_drive_turn) + + router = AgentChatRouter(MagicMock()) + await asyncio.gather( + router._run_acp_turn("a1", "m1", "t1", None), + router._run_acp_turn("a2", "m2", "t2", None), + ) + assert max_concurrent == 2 + + +@pytest.mark.asyncio +async def test_run_acp_turn_drive_failure_does_not_wedge_loop(monkeypatch): + """drive_turn raising must not skip reach_safe_point: the loop returns + to IDLE and a subsequent message is still driven.""" + from tinyagentos.agent_loop import LoopState + import tinyagentos.openclaw_acp_runtime as rt + + calls: list[str] = [] + + async def fake_drive_turn(*, slug, text, trace_id, record_reply): + calls.append(text) + if text == "boom": + raise RuntimeError("turn exploded") + + monkeypatch.setattr(rt, "drive_turn", fake_drive_turn) + + router = AgentChatRouter(MagicMock()) + # Must not raise out of the supervised task path. + await router._run_acp_turn("a1", "boom", "t1", None) + assert router._agent_loops["a1"].state is LoopState.IDLE + await router._run_acp_turn("a1", "again", "t2", None) + assert calls == ["boom", "again"] + + +@pytest.mark.asyncio +async def test_run_acp_turn_queued_message_survives_drive_failure(monkeypatch): + """A message queued behind a FAILING turn is still driven at the safe + point (the finally-drain invariant).""" + import tinyagentos.openclaw_acp_runtime as rt + + driven: list[str] = [] + + async def fake_drive_turn(*, slug, text, trace_id, record_reply): + await asyncio.sleep(0.02) + driven.append(text) + if text == "boom": + raise RuntimeError("turn exploded") + + monkeypatch.setattr(rt, "drive_turn", fake_drive_turn) + + router = AgentChatRouter(MagicMock()) + await asyncio.gather( + router._run_acp_turn("a1", "boom", "t1", None), + router._run_acp_turn("a1", "after", "t2", None), + ) + assert driven == ["boom", "after"] diff --git a/tests/test_taos_agent_chat.py b/tests/test_taos_agent_chat.py index 2e14c0c88..0568b7e24 100644 --- a/tests/test_taos_agent_chat.py +++ b/tests/test_taos_agent_chat.py @@ -654,3 +654,193 @@ async def close(self): assert len(delta_items) == 1 assert delta_items[0]["delta"] == "pong" assert items[-1] == {"done": True} + + +# --------------------------------------------------------------------------- +# AgentLoop serialization: concurrent POSTs no longer race on the shared +# opencode session (tsk-icpt4i) +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_chat_concurrent_second_request_queued_and_surfaced(client, app, monkeypatch): + """While a turn is in flight, a second POST gets a single queued-notice + frame + done, and after the first turn completes its message is surfaced + in the first stream's tail before the final done.""" + await client.patch("/api/taos-agent/settings", json={"model": "gpt-4o"}) + app.state.llm_proxy = _make_mock_proxy(running=True) + app.state.taos_opencode_password = "testpw" + app.state.taos_opencode_session_id = None + + server = _fake_server() + + async def fake_ensure_server(state, model): + return server + + monkeypatch.setattr( + "tinyagentos.routes.taos_agent.ensure_taos_opencode_server", + fake_ensure_server, + ) + + started = asyncio.Event() + release = asyncio.Event() + + class _BlockingAdapter: + def __init__(self, cfg, sink): + self._sink = sink + self.session_id = None + + async def ensure_session(self): + self.session_id = "ses_block" + + async def prompt(self, text, trace_id=None, attachments=None): + self._sink({"kind": "delta", "content": f"reply:{text}"}) + started.set() + await release.wait() + self._sink({"kind": "final", "content": f"reply:{text}"}) + + async def close(self): + pass + + monkeypatch.setattr( + "tinyagentos.routes.taos_agent.OpenCodeAdapter", + _BlockingAdapter, + ) + + first = asyncio.create_task(client.post( + "/api/taos-agent/chat", + json={"messages": [{"role": "user", "content": "first message"}]}, + )) + await asyncio.wait_for(started.wait(), timeout=5) + + # Second request while the first turn is mid-flight → queued notice. + resp2 = await client.post( + "/api/taos-agent/chat", + json={"messages": [{"role": "user", "content": "second message"}]}, + ) + assert resp2.status_code == 200 + items2 = _parse_ndjson(resp2.text) + assert len(items2) == 2 + assert "queued" in items2[0]["delta"].lower() + assert items2[-1] == {"done": True} + + # Let the first turn complete: its stream tail surfaces the queued message. + release.set() + resp1 = await asyncio.wait_for(first, timeout=5) + assert resp1.status_code == 200 + items1 = _parse_ndjson(resp1.text) + tail = [ + i for i in items1 + if "queued message received while working" in i.get("delta", "") + ] + assert len(tail) == 1 + assert "second message" in tail[0]["delta"] + assert items1[-1] == {"done": True} + # The queued frame comes after the turn's own reply delta. + assert items1.index(tail[0]) > items1.index({"delta": "reply:first message"}) + + +@pytest.mark.asyncio +async def test_chat_loop_idle_again_after_turn(client, app, monkeypatch): + """After a completed turn the loop is IDLE, so the next POST is driven + immediately (no stale queued notice).""" + from tinyagentos.agent_loop import LoopState + + await client.patch("/api/taos-agent/settings", json={"model": "gpt-4o"}) + app.state.llm_proxy = _make_mock_proxy(running=True) + app.state.taos_opencode_password = "testpw" + app.state.taos_opencode_session_id = None + + server = _fake_server() + + async def fake_ensure_server(state, model): + return server + + monkeypatch.setattr( + "tinyagentos.routes.taos_agent.ensure_taos_opencode_server", + fake_ensure_server, + ) + + class _NormalAdapter: + def __init__(self, cfg, sink): + self._sink = sink + self.session_id = None + + async def ensure_session(self): + self.session_id = "ses_seq" + + async def prompt(self, text, trace_id=None, attachments=None): + self._sink({"kind": "delta", "content": "pong"}) + self._sink({"kind": "final", "content": "pong"}) + + async def close(self): + pass + + monkeypatch.setattr( + "tinyagentos.routes.taos_agent.OpenCodeAdapter", + _NormalAdapter, + ) + + for _ in range(2): + resp = await client.post( + "/api/taos-agent/chat", + json={"messages": [{"role": "user", "content": "Hi"}]}, + ) + assert resp.status_code == 200 + items = _parse_ndjson(resp.text) + assert {"delta": "pong"} in items + assert not any("queued" in i.get("delta", "").lower() for i in items) + assert app.state.taos_agent_loop.state is LoopState.IDLE + + +# --------------------------------------------------------------------------- +# GET /api/taos-agent/status — scoped payload (tsk-icpt4i) +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_status_defaults_to_idle_without_loop(client): + resp = await client.get("/api/taos-agent/status") + assert resp.status_code == 200 + assert resp.json() == { + "state": "idle", + "current_turn_id": None, + "queued_count": 0, + "subagents": [], + } + + +@pytest.mark.asyncio +async def test_status_scopes_subagent_fields(client, app): + """result/error stay server-side: subagent dicts expose ONLY + id/task/state/started_at.""" + from tinyagentos.agent_loop import AgentLoop + + loop = AgentLoop() + app.state.taos_agent_loop = loop + + async def ok_worker(progress): + return {"secret": "server-side result payload"} + + async def bad_worker(progress): + raise RuntimeError("server-side error detail") + + ok_id = await loop.spawn_subagent("index files", ok_worker) + bad_id = await loop.spawn_subagent("doomed job", bad_worker) + await loop.await_subagent(ok_id) + await loop.await_subagent(bad_id) + + resp = await client.get("/api/taos-agent/status") + assert resp.status_code == 200 + data = resp.json() + assert set(data.keys()) == {"state", "current_turn_id", "queued_count", "subagents"} + assert data["state"] == "idle" + assert data["queued_count"] == 0 + assert len(data["subagents"]) == 2 + by_id = {s["id"]: s for s in data["subagents"]} + assert by_id[ok_id]["state"] == "completed" + assert by_id[bad_id]["state"] == "failed" + for sub in data["subagents"]: + assert set(sub.keys()) == {"id", "task", "state", "started_at"} + assert "result" not in sub + assert "error" not in sub + # The payloads must not leak anywhere in the response body. + assert "server-side" not in resp.text diff --git a/tinyagentos/agent_chat_router.py b/tinyagentos/agent_chat_router.py index 927b3277f..c555df835 100644 --- a/tinyagentos/agent_chat_router.py +++ b/tinyagentos/agent_chat_router.py @@ -5,6 +5,7 @@ import os from typing import Any +from tinyagentos.agent_loop import AgentLoop, LoopAction from tinyagentos.task_utils import _create_supervised_task logger = logging.getLogger(__name__) @@ -34,10 +35,12 @@ def __init__(self, app_state: Any): # Holds in-flight ACP turn tasks so they aren't garbage-collected # before completing (asyncio keeps only weak refs to tasks). self._acp_tasks: set[asyncio.Task] = set() - # Per-agent lock so turns for the same agent run sequentially (a shared - # gateway session can't process two prompts at once) — concurrent - # across different agents. Preserves the bridge's queued-delivery order. - self._agent_locks: dict[str, asyncio.Lock] = {} + # Per-agent AgentLoop owns turn serialization: turns for the same + # agent run sequentially (a shared gateway session can't process two + # prompts at once) — concurrent across different agents. Messages + # arriving mid-turn are queued by the loop and driven by the current + # turn-holder at its safe point, preserving delivery order. + self._agent_loops: dict[str, AgentLoop] = {} async def close(self) -> None: # Cancel + drain any in-flight ACP turns so shutdown doesn't orphan them. @@ -54,14 +57,46 @@ async def close(self) -> None: async def _run_acp_turn( self, agent_name: str, text: str, trace_id, record_reply, ) -> None: - """Drive one OpenClaw ACP turn under the agent's serialization lock.""" + """Drive one OpenClaw ACP turn, serialized per agent by its AgentLoop.""" from tinyagentos.openclaw_acp_runtime import drive_turn - lock = self._agent_locks.setdefault(agent_name, asyncio.Lock()) - async with lock: - await drive_turn( - slug=agent_name, text=text, trace_id=trace_id, record_reply=record_reply, - ) + loop = self._agent_loops.setdefault(agent_name, AgentLoop()) + action = await loop.handle_message(text, msg_id=trace_id) + if action is LoopAction.QUEUED: + # Another turn is in flight for this agent; the current turn-holder + # drives this message at its safe point (drain below). + return + + # Turn-holder: drive the turn, then iteratively (never recursively) + # drive every message queued while it ran. Each queued message keeps + # its own trace id. + pending: list[tuple[str, Any]] = [(text, trace_id)] + while pending: + cur_text, cur_trace = pending.pop(0) + try: + await drive_turn( + slug=agent_name, text=cur_text, trace_id=cur_trace, + record_reply=record_reply, + ) + except Exception as exc: # noqa: BLE001 + # Log like _route does: this task has no other supervisor, and + # a raise must not drop the messages queued behind the failed + # turn (they are still drained and driven below). + logger.warning( + "acp turn for agent %s failed: %s", + agent_name, exc, exc_info=True, + ) + finally: + # MUST run even when drive_turn raises or the task is + # cancelled — skipping it would wedge the loop in WORKING + # forever and silence the agent. + queued = await loop.reach_safe_point() + for m in queued: + followup = await loop.handle_message(m.content, msg_id=m.id) + if followup is LoopAction.IMMEDIATE: + pending.append((m.content, m.id)) + # QUEUED here means another concurrent caller grabbed the + # turn — it will drive this message at its own safe point. def dispatch(self, message: dict, channel: dict) -> None: """Fire-and-forget entry point. Runs routing in a supervised background task.""" diff --git a/tinyagentos/routes/taos_agent.py b/tinyagentos/routes/taos_agent.py index 8c8a22b59..3515bfd5d 100644 --- a/tinyagentos/routes/taos_agent.py +++ b/tinyagentos/routes/taos_agent.py @@ -6,6 +6,7 @@ PUT /api/taos-agent/permitted-models → validate + persist permitted_models; re-scope the agent key PUT /api/taos-agent/persona → persist persona (system-prompt override) POST /api/taos-agent/chat → streams chat completion via opencode (NDJSON) +GET /api/taos-agent/status → scoped agent-loop status (state, turn, queue, subagents) POST /api/taos-agent/attachments/upload → accepts a file, returns a persistent attachment record GET /api/taos-agent/attachments/files/{name} → serve a stored attachment @@ -34,6 +35,7 @@ from pydantic import BaseModel from tinyagentos.adapters.opencode_adapter import OpenCodeAdapter, OpenCodeConfig +from tinyagentos.agent_loop import AgentLoop, LoopAction from tinyagentos.opencode_runtime import OpenCodeBinaryNotFoundError from tinyagentos.taos_agent_runtime import ensure_taos_opencode_server @@ -427,7 +429,10 @@ def sink(reply: dict) -> None: queue.put_nowait({"error": reply.get("error", "error")}) queue.put_nowait(_DONE) elif kind == "final": - queue.put_nowait(_DONE) + # Stream termination is owned by _drive's finally block so + # messages queued during the turn can be surfaced before the + # final done frame. + pass cfg = OpenCodeConfig( base_url=server.base_url, @@ -470,17 +475,62 @@ def sink(reply: dict) -> None: "data_b64": base64.b64encode(data).decode(), }) + # One AgentLoop owns serialization for the desktop taOS agent: two + # concurrent POSTs previously raced on the shared opencode session + # (app_state.taos_opencode_session_id) with no serialization at all. + # Created lazily on app.state like the opencode server/session id. + agent_loop: AgentLoop | None = getattr(app_state, "taos_agent_loop", None) + if agent_loop is None: + agent_loop = AgentLoop() + app_state.taos_agent_loop = agent_loop + + trace_id = uuid.uuid4().hex + action = await agent_loop.handle_message(text, msg_id=trace_id) + if action is LoopAction.QUEUED: + # The agent is mid-turn: the message is queued (never dropped) and + # the turn-holder surfaces it at its safe point in _drive's finally. + # Full multi-turn redrive in this endpoint is out of scope — the + # queued message is surfaced into the turn-holder's stream, not + # driven as its own turn. + async def _queued_stream(): + yield json.dumps({ + "delta": ( + "[The taOS agent is still working on a previous message. " + "Your message has been queued and will be surfaced to the " + "agent when the current turn completes.]" + ), + }) + "\n" + yield json.dumps({"done": True}) + "\n" + + return StreamingResponse( + _queued_stream(), + media_type="application/x-ndjson", + ) + async def _drive() -> None: try: await adapter.ensure_session() app_state.taos_opencode_session_id = adapter.session_id - trace_id = uuid.uuid4().hex await adapter.prompt(text, trace_id=trace_id, attachments=attachments) await adapter.close() except Exception as exc: logger.exception("taos-agent: drive task error") queue.put_nowait({"error": str(exc)}) finally: + # The safe point MUST be reached even when the turn raises or is + # cancelled — skipping it would wedge the loop in WORKING forever. + try: + queued_msgs = await agent_loop.reach_safe_point() + except Exception: + logger.exception("taos-agent: reach_safe_point failed") + queued_msgs = [] + # Surface messages queued while the turn ran so they are never + # silently dropped. Redriving them as their own turns is out of + # scope here; they land in this stream's tail. + for m in queued_msgs: + queue.put_nowait({ + "delta": f"\n[queued message received while working: {m.content}]\n", + }) queue.put_nowait(_DONE) drive_task = asyncio.create_task(_drive()) @@ -523,3 +573,37 @@ async def _generate(): _generate(), media_type="application/x-ndjson", ) + + +@router.get("/api/taos-agent/status") +async def get_status(request: Request): + """Return the desktop agent loop's status, scoped for the UI. + + Only ``state``, ``current_turn_id``, ``queued_count`` and, per subagent, + ``id`` / ``task`` / ``state`` / ``started_at`` are exposed. Subagent + ``result`` / ``error`` payloads are deliberately stripped — they stay + server-side. + """ + agent_loop: AgentLoop | None = getattr(request.app.state, "taos_agent_loop", None) + if agent_loop is None: + return JSONResponse({ + "state": "idle", + "current_turn_id": None, + "queued_count": 0, + "subagents": [], + }) + full = agent_loop.status() + return JSONResponse({ + "state": full["state"], + "current_turn_id": full["current_turn_id"], + "queued_count": full["queued_count"], + "subagents": [ + { + "id": s["id"], + "task": s["task"], + "state": s["state"], + "started_at": s["started_at"], + } + for s in full["subagents"] + ], + }) From 27f03ab2901229552b65c4b60657db5a6bc5d808 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Mon, 10 Aug 2026 06:51:02 +0000 Subject: [PATCH 20/56] fix(registry): load context_window onto AppManifest from catalog YAML AppManifest declared no context_window field and from_dict never read the YAML value, so every catalog manifest loaded as 0. All consumers read it via getattr(manifest, "context_window", 0), so the chat context-window budget code (routes/store.py, routes/store_install.py, routes/taosmd.py, agent_chat_router.py, routes/agents.py, chat/reactions.py) always hit the 4000-token "unknown window" fallback. Add context_window: int = 0 to AppManifest (0 == unknown, preserving the existing getattr fallback semantics) and wire it into from_dict. Real windows now flow into history_token_budget, which floors tiny windows (e.g. rkllm 4096 -> 512) instead of defaulting to 4000. Tests (red-first): a manifest declaring context_window=4096 loads onto AppManifest; a manifest without it defaults to 0. Plus a build_context_window budget test for a known small 4096-token window, asserting oldest-first trimming under the resulting 512-token budget. #2338, #1740 --- changelog.d/tsk-ppgpln-context-window.md | 8 ++++++++ tests/test_chat_context_window.py | 25 ++++++++++++++++++++++++ tests/test_registry.py | 23 ++++++++++++++++++++++ tinyagentos/registry.py | 2 ++ 4 files changed, 58 insertions(+) create mode 100644 changelog.d/tsk-ppgpln-context-window.md diff --git a/changelog.d/tsk-ppgpln-context-window.md b/changelog.d/tsk-ppgpln-context-window.md new file mode 100644 index 000000000..3ff41ec0b --- /dev/null +++ b/changelog.d/tsk-ppgpln-context-window.md @@ -0,0 +1,8 @@ +### Fixed + +- **Catalog manifests' `context_window` was silently dropped**: `AppManifest` + declared no `context_window` field and `from_dict` never read the YAML + value, so every manifest loaded as 0 and the chat context-window budget code + always fell back to the 4000-token "unknown window" default. The field now + loads onto `AppManifest` (0 reserved for unknown), so real windows — e.g. + rkllm 4096, qwen 32768 — drive the #1740 budget math. (#2338, #1740) diff --git a/tests/test_chat_context_window.py b/tests/test_chat_context_window.py index bb5aab7b2..0a0458796 100644 --- a/tests/test_chat_context_window.py +++ b/tests/test_chat_context_window.py @@ -30,6 +30,31 @@ def test_history_token_budget_floored_at_512(): assert history_token_budget(1) == 512 +def test_history_token_budget_known_small_rkllm_window(): + # rkllm manifests (e.g. qwen2.5-1.5b-rkllm) declare 4096. 4096 - 10000 + # system reserve - 1024 response reserve underflows, so the 512 floor + # applies rather than the 4000 unknown-default (#1740). + assert history_token_budget(4096) == 512 + + +def test_build_context_window_budgets_for_known_small_window(): + # With a 4096-token model the history budget is only 512 tokens, so a + # window of chatty messages must be trimmed hard and oldest-first, never + # exceeding that small budget (the #1740 budget math with a real value). + budget = history_token_budget(4096) + assert budget == 512 + + message = "x" * 400 # 100 tokens per message + msgs = [_msg("user", message) for _ in range(20)] # 2000 tokens total + ctx = build_context_window(msgs, limit=20, max_tokens=budget) + + total = sum(estimate_tokens(m["content"]) for m in ctx) + assert total <= budget + assert len(ctx) < 20 + # Oldest dropped first -> the kept set is a contiguous suffix. + assert [m["content"] for m in ctx] == [message] * len(ctx) + + def test_history_token_budget_large_window_subtracts_reserves(): # 32768 - 10000 - 1024 = 21744 assert history_token_budget(32768) == 21744 diff --git a/tests/test_registry.py b/tests/test_registry.py index df169371f..1cf343ff7 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -106,6 +106,29 @@ def test_weights_license_fields_load_when_present(self, tmp_path): assert m.weights_license == "CC-BY-NC 4.0" assert m.license_class == "non-commercial" + def test_context_window_survives_when_present(self, tmp_path): + """A model manifest declaring ``context_window`` must surface it on the + loaded AppManifest object, not silently drop it to 0 (#1740 loader bug).""" + d = tmp_path / "models" / "rkllm-model" + d.mkdir(parents=True) + (d / "manifest.yaml").write_text(yaml.dump({ + "id": "rkllm-model", + "name": "RKLLM Model", + "type": "model", + "version": "1.0.0", + "variants": [{"id": "w8a8", "format": "rkllm", "size_mb": 2040, + "download_url": "https://example.com/x.rkllm"}], + "context_window": 4096, + })) + m = AppManifest.from_file(d / "manifest.yaml") + assert m.context_window == 4096 + + def test_context_window_defaults_to_zero_when_absent(self, catalog_dir): + """A manifest without ``context_window`` (e.g. a service) must report 0, + preserving the unknown-window semantics consumers rely on.""" + m = AppManifest.from_file(catalog_dir / "services" / "gitea" / "manifest.yaml") + assert m.context_window == 0 + class TestAppRegistry: def test_load_catalog(self, registry): diff --git a/tinyagentos/registry.py b/tinyagentos/registry.py index 0c374c485..968d77ce9 100644 --- a/tinyagentos/registry.py +++ b/tinyagentos/registry.py @@ -47,6 +47,7 @@ class AppManifest: hardware_tiers: dict = field(default_factory=dict) config_schema: list = field(default_factory=list) variants: list = field(default_factory=list) # models only + context_window: int = 0 # model token context window; 0 = unknown capabilities: list = field(default_factory=list) lifecycle: dict = field(default_factory=dict) manifest_dir: Path | None = None @@ -75,6 +76,7 @@ def from_dict(cls, data: dict, manifest_dir: Path | None = None) -> AppManifest: hardware_tiers=data.get("hardware_tiers", {}), config_schema=data.get("config_schema", []), variants=data.get("variants", []), + context_window=data.get("context_window", 0), capabilities=data.get("capabilities", []), lifecycle=data.get("lifecycle", {}), manifest_dir=manifest_dir, From 4627980166558579f6ebf18e8e077c1e90e58b37 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Mon, 10 Aug 2026 07:14:58 +0000 Subject: [PATCH 21/56] Add sweep test for model catalog manifest integrity --- tests/test_model_manifest_integrity.py | 200 +++++++++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 tests/test_model_manifest_integrity.py diff --git a/tests/test_model_manifest_integrity.py b/tests/test_model_manifest_integrity.py new file mode 100644 index 000000000..ef3e25e8a --- /dev/null +++ b/tests/test_model_manifest_integrity.py @@ -0,0 +1,200 @@ +"""Sweep test for model-catalog manifest integrity. + +Ensures every variant in app-catalog/models/*/manifest.yaml satisfies the +resolver schema contract: non-empty backends with known targets, a 64-char +lowercase hex sha256, a non-empty https download_url, and a positive size_mb. + +A per-manifest allowlist tracks pre-existing sha256 debt until the catalog +is filled in. The intent is zero entries. +""" +from __future__ import annotations + +import glob +import re +from pathlib import Path + +import yaml + +# Known target enums -- the resolver only accepts values produced by +# hardware_to_targets in tinyagentos/cluster/capabilities.py. +KNOWN_TARGETS = { + "apple-silicon", + "x86-cuda", + "x86-vulkan", + "arm-vulkan", + "rockchip", + "cpu", +} + +# Pre-existing debt: every model manifest ships without sha256 (None or +# empty string). IDs listed here are exempt from the sha256 rule until +# the upstream catalog is filled in. The intent is zero entries. +_SHA256_ALLOWLIST: set[str] = { + "4x-ultrasharp", + "auraflow-v0.3", + "bge-large-en-v1.5", + "bge-m3", + "bge-reranker-v2-m3", + "bge-small-en-v1.5", + "birefnet", + "codeformer", + "command-r-35b", + "controlnet-canny", + "controlnet-depth", + "controlnet-openpose", + "controlnet-openpose-sdxl", + "deepseek-coder-v2-lite", + "deepseek-r1-14b", + "dreamshaper-8-lcm", + "florence-2-base", + "flux-dev-gguf", + "flux-schnell-gguf", + "flux-schnell-unsloth", + "gemma-2-2b", + "gemma-2-9b", + "gemma-3-12b", + "gemma-3-1b", + "gemma-3-4b", + "gemma-4-e2b-gguf", + "gemma-4-e2b-uncensored-gguf", + "gemma-4-e4b-gguf", + "gemma-4-e4b-uncensored-gguf", + "gfpgan-v1.4", + "granite-3.1-2b", + "granite-3.1-8b", + "jina-embeddings-v3", + "kokoro-tts", + "kolors", + "lcm-dreamshaper-v7", + "llama-3-70b", + "llama-3.1-8b", + "llama-3.2-1b", + "llama-3.2-3b", + "llama-3.3-70b", + "llava-1.6-mistral-7b", + "llava-phi-3-mini", + "ltx-video", + "minicpm-v-2.6", + "ministral-3b", + "mistral-7b-v0.3", + "mistral-nemo-12b", + "mixtral-8x7b", + "moondream2", + "mxbai-embed-large", + "nemotron-mini-4b", + "nomic-embed-text-v1.5", + "paligemma-2", + "parakeet-tdt-0.6b", + "pelochus-qwen-1.8b-rkllm", + "phi-3.5-mini", + "phi-4", + "phi-4-mini", + "piper-en-lessac", + "pixart-sigma-512", + "playground-v2.5", + "qwen2-vl-7b", + "qwen2.5-vl-7b", + "qwen2.5-0.5b", + "qwen2.5-1.5b", + "qwen2.5-1.5b-rkllm", + "qwen2.5-14b", + "qwen2.5-14b-rkllm", + "qwen2.5-32b", + "qwen2.5-3b", + "qwen2.5-3b-rkllm", + "qwen2.5-72b", + "qwen2.5-7b", + "qwen2.5-7b-rkllm", + "qwen2.5-coder-1.5b-rkllm", + "qwen2.5-coder-14b", + "qwen2.5-coder-14b-rkllm", + "qwen2.5-coder-7b", + "qwen2.5-coder-7b-rkllm", + "qwen2.5-math-1.5b-rkllm", + "qwen2.5-math-7b-rkllm", + "qwen3-1.7b", + "qwen3-1.7b-rkllm", + "qwen3-14b", + "qwen3-30b-a3b", + "qwen3-32b", + "qwen3-4b", + "qwen3-4b-rkllm", + "qwen3-8b", + "qwen3-embedding-0.6b", + "qwen3-reranker-0.6b", + "qwen3-vl-2b-rkllm", + "qwen3-vl-4b-rkllm", + "real-esrgan-x4", + "rmbg-1.4", + "sd-v1.5-lcm", + "sd3.5-large-turbo-gguf", + "sdxl-lightning", + "sdxl-turbo", + "sdxs-512", + "smollm2", + "smollm2-135m", + "smollm2-360m", + "smolvlm", + "snowflake-arctic-embed-m", + "snowflake-arctic-embed-s", + "stable-cascade", + "tinyllama-1.1b", + "whisper-base", + "whisper-large-v3", + "whisper-large-v3-turbo", + "whisper-medium", + "whisper-small", + "whisper-tiny", +} + + +def test_model_manifests_are_resolvable_and_integrity_pinned(): + root = Path(__file__).resolve().parent.parent / "app-catalog" + errors: list[str] = [] + for path in sorted(glob.glob(str(root / "models" / "*" / "manifest.yaml"))): + with open(path) as f: + manifest = yaml.safe_load(f) + mid = manifest.get("id") or Path(path).parent.name + allowed_sha256 = mid in _SHA256_ALLOWLIST + for variant in manifest.get("variants") or []: + vid = variant.get("id", "") + # Rule 1: requires.backends non-empty; every entry has non-empty + # targets whose values are drawn from the known enum set. + backends = ((variant.get("requires") or {}).get("backends")) or [] + if not backends: + errors.append(f"{mid}/{vid}: requires.backends is empty") + continue + for backend in backends: + targets = backend.get("targets") or [] + if not targets: + errors.append( + f"{mid}/{vid}: backend {backend.get('id')!r} has empty targets" + ) + else: + unknown = [t for t in targets if t not in KNOWN_TARGETS] + if unknown: + errors.append( + f"{mid}/{vid}: backend {backend.get('id')!r} has unknown targets {unknown}" + ) + # Rule 2: sha256 is a 64-char lowercase hex string. + sha256 = variant.get("sha256") + if not re.fullmatch(r"[0-9a-f]{64}", sha256 or ""): + if not allowed_sha256: + errors.append( + f"{mid}/{vid}: sha256 must be a 64-char lowercase hex string (got {sha256!r})" + ) + # Rule 3: download_url is non-empty and parses as https. + url = variant.get("download_url", "") + if not url or not url.startswith("https://"): + errors.append( + f"{mid}/{vid}: download_url must be a non-empty https URL (got {url!r})" + ) + # Rule 4: size_mb is a positive int. + size_mb = variant.get("size_mb") + if not isinstance(size_mb, int) or size_mb <= 0: + errors.append( + f"{mid}/{vid}: size_mb must be a positive int (got {size_mb!r})" + ) + assert errors == [], ( + "model manifest integrity failures:\n" + "\n".join(errors) + ) From 8b2d0a2c49f348ea084f158c89770b5f4bfebc2e Mon Sep 17 00:00:00 2001 From: jaylfc Date: Mon, 10 Aug 2026 07:19:25 +0000 Subject: [PATCH 22/56] test(catalog): derive KNOWN_TARGETS from capabilities.py instead of hardcoding The sweep test's hardcoded target set (merged in #2344) drifts the moment a new backend target lands in hardware_to_targets: the pending Hailo backend work adds targets: [hailo], and the hardcoded set would have bounced exactly the PR introducing it. Proven both ways before this fix: with a simulated hailo branch + hailo-target manifest, the hardcoded set fails the sweep and the derived set passes it; the derived set still fails on a genuinely broken manifest (negative size_mb probe). --- tests/test_model_manifest_integrity.py | 32 ++++++++++++++++++-------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/tests/test_model_manifest_integrity.py b/tests/test_model_manifest_integrity.py index ef3e25e8a..311f9f05c 100644 --- a/tests/test_model_manifest_integrity.py +++ b/tests/test_model_manifest_integrity.py @@ -16,15 +16,29 @@ import yaml # Known target enums -- the resolver only accepts values produced by -# hardware_to_targets in tinyagentos/cluster/capabilities.py. -KNOWN_TARGETS = { - "apple-silicon", - "x86-cuda", - "x86-vulkan", - "arm-vulkan", - "rockchip", - "cpu", -} +# hardware_to_targets in tinyagentos/cluster/capabilities.py. DERIVED from +# that source file rather than hardcoded: a literal copy silently drifts the +# moment a new backend target lands (adding "hailo" there would have made +# this sweep bounce the very PR that introduced it). +_CAPABILITIES_SRC = ( + Path(__file__).resolve().parent.parent + / "tinyagentos" / "cluster" / "capabilities.py" +).read_text() +KNOWN_TARGETS = set( + re.findall(r'targets\.append\(\s*"([a-z0-9-]+)"', _CAPABILITIES_SRC) +) | set( + # conditional-expression appends: targets.append("a" if cond else "b") + t + for pair in re.findall( + r'targets\.append\(\s*"([a-z0-9-]+)" if .+ else "([a-z0-9-]+)"', + _CAPABILITIES_SRC, + ) + for t in pair +) +assert len(KNOWN_TARGETS) >= 6, ( + f"target derivation collapsed ({sorted(KNOWN_TARGETS)}) - " + "capabilities.py changed shape; fix the extraction, do not hardcode" +) # Pre-existing debt: every model manifest ships without sha256 (None or # empty string). IDs listed here are exempt from the sha256 rule until From 8c0caca175a5ff579e6dfa1840f47da9de421ef2 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Mon, 10 Aug 2026 07:26:39 +0000 Subject: [PATCH 23/56] fix(tasks): record actual from_status on quarantine; document claimable route quarantine_task hardcoded from_status="open" in _record_audit, but its WHERE clause permits quarantining a claimed card, so the audit trail logged a false "open to quarantined" transition for claimed cards. Mirror close_task's race-free derivation from the committed row's claimed_by so the audit records the true pre-quarantine status. Add a test that quarantines a claimed task and asserts from_status == "claimed". Also document the LEAD-only mark-task-claimable route (POST .../tasks/{id}/claimable) in the project_tasks scope bullet of docs/agent-coordination.md -- it was allowlisted in auth_middleware.py but never documented, unlike the unquarantine route added with the strike-store wiring. --- docs/agent-coordination.md | 5 +++++ tests/test_task_store.py | 21 +++++++++++++++++++++ tinyagentos/projects/task_store.py | 7 ++++++- 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/docs/agent-coordination.md b/docs/agent-coordination.md index 9e72a0ce4..3fad449dc 100644 --- a/docs/agent-coordination.md +++ b/docs/agent-coordination.md @@ -229,6 +229,11 @@ The surface, by scope: `POST .../tasks/{id}/(claim|release|close|reopen)`, and `GET /api/projects/tasks/{id}/context`. This is read + lifecycle + comments only. Granting project_tasks also makes the agent a project member. + `POST .../tasks/{id}/claimable` is also reachable, but LEAD-only: the + route (`_authorize_project_lead`) refuses a plain project_tasks worker. + It toggles only the `claimable` label (the fleet-pickup flag), preserving + every other label, so it does not widen the scope into free field edits + (cf. PATCH). `POST .../tasks/{id}/unquarantine` is also reachable, but LEAD-only: the route (`_authorize_project_lead`) refuses a plain project_tasks worker. It returns a quarantined card to the open pool and clears its strikes. diff --git a/tests/test_task_store.py b/tests/test_task_store.py index b20b8719e..61331f44f 100644 --- a/tests/test_task_store.py +++ b/tests/test_task_store.py @@ -2,6 +2,7 @@ import pytest +from tinyagentos.board_audit import BoardAuditLog from tinyagentos.projects import task_store as task_store_mod from tinyagentos.projects.task_store import ProjectTaskStore @@ -599,3 +600,23 @@ async def test_no_broker_no_error(tmp_path): await s.claim_task(task["id"], "worker-1") await s.close_task(task["id"], "worker-1") await s.close() + + +@pytest.mark.asyncio +async def test_quarantine_claimed_task_records_actual_from_status(tmp_path): + audit = BoardAuditLog(tmp_path / "audit.db") + await audit.init() + s = ProjectTaskStore(tmp_path / "tasks.db", audit=audit) + await s.init() + try: + task = await s.create_task("prj-1", "Task", "alice") + await s.claim_task(task["id"], "worker-1") + ok = await s.quarantine_task(task["id"], "system") + assert ok is True + history = await audit.history(task["id"]) + quarantined = [h for h in history if h["event"] == "task.quarantined"] + assert len(quarantined) == 1 + assert quarantined[0]["from_status"] == "claimed" + finally: + await s.close() + await audit.close() diff --git a/tinyagentos/projects/task_store.py b/tinyagentos/projects/task_store.py index fd039fcd9..b71507267 100644 --- a/tinyagentos/projects/task_store.py +++ b/tinyagentos/projects/task_store.py @@ -446,8 +446,13 @@ async def quarantine_task(self, task_id: str, actor: str) -> bool: "task.quarantined", {"id": task_id, "actor": actor}, ) + # Derive the pre-quarantine status race-free from the committed row + # rather than a separate pre-read (which would have a TOCTOU gap). + # quarantine does not clear claimed_by, so a set claimer means it was + # 'claimed' (cf. close_task's derivation). + from_status = "claimed" if existing and existing.get("claimed_by") else "open" await self._record_audit( - task_id, "task.quarantined", actor, "open", "quarantined", + task_id, "task.quarantined", actor, from_status, "quarantined", project_id=existing["project_id"] if existing else "", ) return changed From 329201819d5ee7b6886f02476669e2aec7c13a19 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Mon, 10 Aug 2026 07:32:47 +0000 Subject: [PATCH 24/56] chat: add exporter and transformer for A2A bus import - ChatExporter reads ChatMessageStore and produces import-batch envelopes - Explicit identity_map, whole-batch failure on unmapped author - Flattened body from content_blocks, non-empty when blocks exist - 64KB serialized limit, oversized content delegated to file_writer - Deterministic ordering by (created_at ASC, id ASC) - reply_to preserved for non-deleted parents, omitted for orphans - Idempotent on (source, source_id), re-run produces byte-identical output - 18 pytest tests covering all acceptance criteria --- tests/test_chat_exporter.py | 440 ++++++++++++++++++++++++++++++ tinyagentos/chat/chat_exporter.py | 142 ++++++++++ 2 files changed, 582 insertions(+) create mode 100644 tests/test_chat_exporter.py create mode 100644 tinyagentos/chat/chat_exporter.py diff --git a/tests/test_chat_exporter.py b/tests/test_chat_exporter.py new file mode 100644 index 000000000..f5f1a3f17 --- /dev/null +++ b/tests/test_chat_exporter.py @@ -0,0 +1,440 @@ +from __future__ import annotations + +import json +import time +from pathlib import Path + +import pytest +import pytest_asyncio + +from tinyagentos.chat.chat_exporter import ChatExportError, ChatExporter, flatten_body +from tinyagentos.chat.message_store import ChatMessageStore + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest_asyncio.fixture +async def store(tmp_path): + s = ChatMessageStore(tmp_path / "chat.db") + await s.init() + yield s + await s.close() + + +def _make_file_writer(root: Path): + async def writer(source_id: str, content: bytes) -> str: + dest = root / "chat-export" / f"{source_id}.txt" + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_bytes(content) + return f"chat-export/{source_id}.txt" + + return writer + + +def _identity_map(user_ids: list[str], agent_ids: list[str] | None = None) -> dict[str, str]: + m: dict[str, str] = {} + for uid in user_ids: + m[uid] = f"@{uid}" + for aid in (agent_ids or []): + m[aid] = f"@{aid}" + return m + + +# --------------------------------------------------------------------------- +# flatten_body unit tests +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_flatten_empty_blocks(): + assert flatten_body([]) == "" + assert flatten_body(None) == "" + + +@pytest.mark.asyncio +async def test_flatten_text_blocks(): + blocks = [ + {"type": "paragraph", "text": "Hello"}, + {"type": "code", "lang": "py", "text": "print(1)"}, + ] + assert flatten_body(blocks) == "Hello\nprint(1)" + + +@pytest.mark.asyncio +async def test_flatten_skips_empty_text(): + blocks = [ + {"type": "paragraph", "text": "Hello"}, + {"type": "image", "url": "http://x/img.png"}, + {"type": "paragraph", "text": ""}, + ] + assert flatten_body(blocks) == "Hello" + + +# --------------------------------------------------------------------------- +# Exporter integration tests +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_export_channel_produces_valid_batch(store, tmp_path): + await store.send_message( + channel_id="ch1", + author_id="user1", + author_type="user", + content="hello", + content_blocks=[{"type": "paragraph", "text": "hello"}], + ) + await store.send_message( + channel_id="ch1", + author_id="agent-1", + author_type="agent", + content="hi", + content_blocks=[{"type": "paragraph", "text": "hi"}], + ) + + exporter = ChatExporter( + message_store=store, + identity_map=_identity_map(["user1"], ["agent-1"]), + ) + batch = await exporter.export_channel("ch1") + + assert len(batch) == 2 + for env in batch: + assert env["from"] + assert env["thread"] == "ch1" + assert env["ts"] > 0 + assert env["source"] == "taos-chat" + assert env["source_id"] + assert isinstance(env["blocks"], list) + + +@pytest.mark.asyncio +async def test_every_message_has_non_empty_body(store): + await store.send_message( + channel_id="ch1", + author_id="user1", + author_type="user", + content="", + content_blocks=[{"type": "paragraph", "text": "blocks present"}], + ) + await store.send_message( + channel_id="ch1", + author_id="user1", + author_type="user", + content="plain text", + content_blocks=[], + ) + + exporter = ChatExporter( + message_store=store, + identity_map=_identity_map(["user1"]), + ) + batch = await exporter.export_channel("ch1") + assert len(batch) == 2 + for env in batch: + assert env["body"] != "" + + +@pytest.mark.asyncio +async def test_unmapped_author_fails_whole_batch(store): + await store.send_message( + channel_id="ch1", + author_id="user1", + author_type="user", + content="first", + ) + await store.send_message( + channel_id="ch1", + author_id="unknown-user", + author_type="user", + content="second", + ) + + exporter = ChatExporter( + message_store=store, + identity_map=_identity_map(["user1"]), + ) + with pytest.raises(ChatExportError, match="unmapped author_id 'unknown-user'"): + await exporter.export_channel("ch1") + + +@pytest.mark.asyncio +async def test_reexport_produces_byte_identical_output(store, tmp_path): + for i in range(3): + await store.send_message( + channel_id="ch1", + author_id="user1", + author_type="user", + content=f"msg{i}", + content_blocks=[{"type": "paragraph", "text": f"msg{i}"}], + ) + + exporter = ChatExporter( + message_store=store, + identity_map=_identity_map(["user1"]), + ) + batch1 = await exporter.export_channel("ch1") + batch2 = await exporter.export_channel("ch1") + + assert json.dumps(batch1, sort_keys=True, ensure_ascii=False) == json.dumps( + batch2, sort_keys=True, ensure_ascii=False + ) + + +@pytest.mark.asyncio +async def test_oversized_content_becomes_ref(store, tmp_path): + big_text = "x" * 100_000 + await store.send_message( + channel_id="ch1", + author_id="user1", + author_type="user", + content=big_text, + content_blocks=[{"type": "paragraph", "text": big_text}], + ) + + writer = _make_file_writer(tmp_path) + exporter = ChatExporter( + message_store=store, + identity_map=_identity_map(["user1"]), + file_writer=writer, + ) + batch = await exporter.export_channel("ch1") + assert len(batch) == 1 + env = batch[0] + assert env["blocks"] == [] + assert "chat-export/" in env["body"] + assert env["body"].endswith(".txt") + written = (tmp_path / env["body"]).read_bytes() + assert written.decode("utf-8") == big_text + + +@pytest.mark.asyncio +async def test_thread_ordering_preserved(store): + ts = time.time() + for i in range(3): + await store.send_message( + channel_id="ch1", + author_id="user1", + author_type="user", + content=f"msg{i}", + content_blocks=[{"type": "paragraph", "text": f"msg{i}"}], + ) + await store._db.execute( + "UPDATE chat_messages SET created_at = ? WHERE id = (SELECT id FROM chat_messages ORDER BY created_at DESC LIMIT 1)", + (ts,), + ) + await store._db.commit() + + exporter = ChatExporter( + message_store=store, + identity_map=_identity_map(["user1"]), + ) + batch1 = await exporter.export_channel("ch1") + batch2 = await exporter.export_channel("ch1") + ids1 = [e["source_id"] for e in batch1] + ids2 = [e["source_id"] for e in batch2] + assert ids1 == ids2 + assert len(ids1) == 3 + + +@pytest.mark.asyncio +async def test_reply_to_relationships_preserved(store): + parent = await store.send_message( + channel_id="ch1", + author_id="user1", + author_type="user", + content="parent", + content_blocks=[{"type": "paragraph", "text": "parent"}], + ) + reply = await store.send_message( + channel_id="ch1", + author_id="user2", + author_type="user", + content="reply", + content_blocks=[{"type": "paragraph", "text": "reply"}], + thread_id=parent["id"], + ) + + exporter = ChatExporter( + message_store=store, + identity_map=_identity_map(["user1", "user2"]), + ) + batch = await exporter.export_channel("ch1") + reply_env = next(e for e in batch if e["source_id"] == reply["id"]) + assert reply_env.get("reply_to") == parent["id"] + parent_env = next(e for e in batch if e["source_id"] == parent["id"]) + assert "reply_to" not in parent_env + + +@pytest.mark.asyncio +async def test_deleted_parent_omits_reply_to(store): + parent = await store.send_message( + channel_id="ch1", + author_id="user1", + author_type="user", + content="parent", + content_blocks=[{"type": "paragraph", "text": "parent"}], + ) + await store.soft_delete_message(parent["id"]) + reply = await store.send_message( + channel_id="ch1", + author_id="user2", + author_type="user", + content="reply", + content_blocks=[{"type": "paragraph", "text": "reply"}], + thread_id=parent["id"], + ) + + exporter = ChatExporter( + message_store=store, + identity_map=_identity_map(["user1", "user2"]), + ) + batch = await exporter.export_channel("ch1") + reply_env = next(e for e in batch if e["source_id"] == reply["id"]) + assert "reply_to" not in reply_env + + +@pytest.mark.asyncio +async def test_export_all_channels(store): + await store.send_message( + channel_id="ch1", + author_id="user1", + author_type="user", + content="ch1-msg", + content_blocks=[{"type": "paragraph", "text": "ch1-msg"}], + ) + await store.send_message( + channel_id="ch2", + author_id="user1", + author_type="user", + content="ch2-msg", + content_blocks=[{"type": "paragraph", "text": "ch2-msg"}], + ) + + exporter = ChatExporter( + message_store=store, + identity_map=_identity_map(["user1"]), + ) + batch = await exporter.export_all_channels(["ch1", "ch2"]) + assert len(batch) == 2 + threads = {e["thread"] for e in batch} + assert threads == {"ch1", "ch2"} + + +@pytest.mark.asyncio +async def test_blocks_preserved_in_envelope(store): + blocks = [ + {"type": "paragraph", "text": "line1"}, + {"type": "code", "lang": "python", "text": "print(1)"}, + ] + await store.send_message( + channel_id="ch1", + author_id="user1", + author_type="user", + content="line1\nprint(1)", + content_blocks=blocks, + ) + + exporter = ChatExporter( + message_store=store, + identity_map=_identity_map(["user1"]), + ) + batch = await exporter.export_channel("ch1") + assert batch[0]["blocks"] == blocks + + +@pytest.mark.asyncio +async def test_ts_preserved(store): + msg = await store.send_message( + channel_id="ch1", + author_id="user1", + author_type="user", + content="hello", + content_blocks=[{"type": "paragraph", "text": "hello"}], + ) + + exporter = ChatExporter( + message_store=store, + identity_map=_identity_map(["user1"]), + ) + batch = await exporter.export_channel("ch1") + assert batch[0]["ts"] == msg["created_at"] + + +@pytest.mark.asyncio +async def test_source_id_is_original_message_id(store): + msg = await store.send_message( + channel_id="ch1", + author_id="user1", + author_type="user", + content="hello", + content_blocks=[{"type": "paragraph", "text": "hello"}], + ) + + exporter = ChatExporter( + message_store=store, + identity_map=_identity_map(["user1"]), + ) + batch = await exporter.export_channel("ch1") + assert batch[0]["source_id"] == msg["id"] + + +@pytest.mark.asyncio +async def test_custom_source_identifier(store): + exporter = ChatExporter( + message_store=store, + identity_map=_identity_map(["user1"]), + source="my-custom-source", + ) + await store.send_message( + channel_id="ch1", + author_id="user1", + author_type="user", + content="hello", + content_blocks=[{"type": "paragraph", "text": "hello"}], + ) + batch = await exporter.export_channel("ch1") + assert batch[0]["source"] == "my-custom-source" + + +@pytest.mark.asyncio +async def test_empty_body_when_no_content(store): + await store.send_message( + channel_id="ch1", + author_id="user1", + author_type="user", + content="", + content_blocks=[], + ) + + exporter = ChatExporter( + message_store=store, + identity_map=_identity_map(["user1"]), + ) + batch = await exporter.export_channel("ch1") + assert batch[0]["body"] == "" + + +@pytest.mark.asyncio +async def test_file_writer_receives_utf8_body(store, tmp_path): + text = "héllo wörld " * 10_000 + await store.send_message( + channel_id="ch1", + author_id="user1", + author_type="user", + content=text, + content_blocks=[{"type": "paragraph", "text": text}], + ) + + writer = _make_file_writer(tmp_path) + exporter = ChatExporter( + message_store=store, + identity_map=_identity_map(["user1"]), + file_writer=writer, + ) + batch = await exporter.export_channel("ch1") + ref = batch[0]["body"] + assert ref.startswith("chat-export/") + assert ref.endswith(".txt") + written = (tmp_path / ref).read_bytes() + assert written.decode("utf-8") == text diff --git a/tinyagentos/chat/chat_exporter.py b/tinyagentos/chat/chat_exporter.py new file mode 100644 index 000000000..b89cefc8c --- /dev/null +++ b/tinyagentos/chat/chat_exporter.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Awaitable, Callable + +_MAX_MESSAGE_BYTES = 64 * 1024 +_DEFAULT_SOURCE = "taos-chat" + + +class ChatExportError(Exception): + """Raised when a batch cannot be exported.""" + + +def flatten_body(blocks: list[dict] | None) -> str: + """Flatten content blocks to plain text.""" + if not blocks: + return "" + parts = [] + for block in blocks: + if isinstance(block, dict): + text = block.get("text") or block.get("content") or "" + if text: + parts.append(str(text)) + elif isinstance(block, str): + parts.append(block) + return "\n".join(parts) + + +def _serialized_size(envelope: dict) -> int: + return len(json.dumps(envelope, ensure_ascii=False).encode("utf-8")) + + +class ChatExporter: + """Export chat messages to A2A bus import batches. + + Reads messages from a ChatMessageStore and transforms them into the + envelope format expected by the taOSmd bus import endpoint. + + Identity mapping is explicit: every author_id in the batch must have a + corresponding handle in identity_map, or the whole batch fails. + """ + + def __init__( + self, + message_store: Any, + identity_map: dict[str, str], + source: str = _DEFAULT_SOURCE, + max_message_bytes: int = _MAX_MESSAGE_BYTES, + file_writer: Callable[[str, bytes], Awaitable[str]] | None = None, + ) -> None: + self._msg_store = message_store + self._identity_map = dict(identity_map) + self._source = source + self._max_message_bytes = max_message_bytes + self._file_writer = file_writer + + async def export_channel(self, channel_id: str) -> list[dict]: + """Export all non-deleted messages from a channel. + + Returns envelopes ordered by (created_at ASC, id ASC) for + deterministic, reproducible output. Raises ChatExportError if any + author_id is unmapped. + """ + messages = await self._msg_store.get_all_messages_for_channel(channel_id) + messages = [m for m in messages if m.get("deleted_at") is None] + messages.sort( + key=lambda m: (m.get("created_at") or 0.0, m.get("id") or "") + ) + + batch: list[dict] = [] + exported_ids: set[str] = set() + + for msg in messages: + envelope = await self._transform_message(msg) + batch.append(envelope) + exported_ids.add(envelope["source_id"]) + + for envelope in batch: + reply_to = envelope.get("reply_to") + if reply_to is not None and reply_to not in exported_ids: + del envelope["reply_to"] + + return batch + + async def _transform_message(self, msg: dict) -> dict: + """Transform a single chat message to a bus envelope. + + Raises ChatExportError if author_id is not in identity_map. + """ + author_id = msg.get("author_id", "") + handle = self._identity_map.get(author_id) + if handle is None: + raise ChatExportError( + f"unmapped author_id {author_id!r} for message {msg.get('id')!r}" + ) + + content_blocks = msg.get("content_blocks") or [] + body = flatten_body(content_blocks) + + if not body and msg.get("content"): + body = str(msg["content"]) + + if content_blocks and not body.strip(): + raise ChatExportError( + f"message {msg.get('id')!r} has content_blocks but empty body" + ) + + source_id = msg.get("id", "") + + envelope: dict[str, Any] = { + "from": handle, + "thread": msg.get("channel_id", ""), + "body": body, + "blocks": content_blocks, + "ts": msg.get("created_at", 0.0), + "source": self._source, + "source_id": source_id, + } + + thread_id = msg.get("thread_id") + if thread_id: + envelope["reply_to"] = thread_id + + if _serialized_size(envelope) > self._max_message_bytes: + if self._file_writer is None: + raise ChatExportError( + f"message {source_id!r} exceeds {self._max_message_bytes} bytes " + f"and no file_writer configured" + ) + ref = await self._file_writer(source_id, body.encode("utf-8")) + envelope["body"] = ref + envelope["blocks"] = [] + + return envelope + + async def export_all_channels(self, channel_ids: list[str]) -> list[dict]: + """Export multiple channels and concatenate the batches.""" + batch: list[dict] = [] + for ch_id in channel_ids: + batch.extend(await self.export_channel(ch_id)) + return batch From d90bd81b6c5dc0a738927458c48f06f852764d64 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Mon, 10 Aug 2026 07:55:54 +0000 Subject: [PATCH 25/56] tsk-msmub3 [OPEN] reopen_task leaves stale claimed_by: reopened task --- docs/agent-coordination.md | 5 +-- tests/test_task_store.py | 71 ++++++++++++++++++++++++++++++ tinyagentos/projects/task_store.py | 5 ++- 3 files changed, 76 insertions(+), 5 deletions(-) diff --git a/docs/agent-coordination.md b/docs/agent-coordination.md index 3fad449dc..0a1b52358 100644 --- a/docs/agent-coordination.md +++ b/docs/agent-coordination.md @@ -231,9 +231,8 @@ The surface, by scope: only. Granting project_tasks also makes the agent a project member. `POST .../tasks/{id}/claimable` is also reachable, but LEAD-only: the route (`_authorize_project_lead`) refuses a plain project_tasks worker. - It toggles only the `claimable` label (the fleet-pickup flag), preserving - every other label, so it does not widen the scope into free field edits - (cf. PATCH). + It adds/removes the `claimable` label in place, preserving all other labels, + so it does not widen the scope into free field edits (cf. PATCH). `POST .../tasks/{id}/unquarantine` is also reachable, but LEAD-only: the route (`_authorize_project_lead`) refuses a plain project_tasks worker. It returns a quarantined card to the open pool and clears its strikes. diff --git a/tests/test_task_store.py b/tests/test_task_store.py index 61331f44f..649469edd 100644 --- a/tests/test_task_store.py +++ b/tests/test_task_store.py @@ -344,6 +344,26 @@ async def test_held_task_none_after_close(tmp_path): await s.close() +@pytest.mark.asyncio +async def test_claim_after_close_and_reopen(tmp_path): + """Red-first: claim -> close -> reopen -> claim by another worker must succeed after fix. + + This test verifies that reopen_task properly clears claimed_by/claimed_at, + making a task claimable again after it was claimed, closed, and reopened. + """ + s = await _store(tmp_path) + task = await s.create_task("prj-1", "Task", "alice") + await s.claim_task(task["id"], "worker-1") + await s.close_task(task["id"], "worker-1") + await s.reopen_task(task["id"], "alice") + ok = await s.claim_task(task["id"], "worker-2") + assert ok is True + fetched = await s.get_task(task["id"]) + assert fetched["claimed_by"] == "worker-2" + assert fetched["status"] == "claimed" + await s.close() + + @pytest.mark.asyncio async def test_update_task_title(tmp_path): s = await _store(tmp_path) @@ -620,3 +640,54 @@ async def test_quarantine_claimed_task_records_actual_from_status(tmp_path): finally: await s.close() await audit.close() + + +@pytest.mark.asyncio +async def test_quarantine_unclaimed_task_records_from_status_open(tmp_path): + """Open-path assertion: unclaimed task -> audit from_status='open'. + + This test verifies that quarantine_task records from_status='open' when + called on an unclaimed task, complementing the existing claimed-task test. + """ + audit = BoardAuditLog(tmp_path / "audit.db") + await audit.init() + s = ProjectTaskStore(tmp_path / "tasks.db", audit=audit) + await s.init() + try: + task = await s.create_task("prj-1", "Task", "alice") + ok = await s.quarantine_task(task["id"], "system") + assert ok is True + history = await audit.history(task["id"]) + quarantined = [h for h in history if h["event"] == "task.quarantined"] + assert len(quarantined) == 1 + assert quarantined[0]["from_status"] == "open" + finally: + await s.close() + await audit.close() + + +@pytest.mark.asyncio +async def test_quarantine_after_reopen_records_from_status_open(tmp_path): + """Quarantine-after-reopen records from_status='open'. + + This test verifies that quarantine_task records from_status='open' when + called on a reopened task (that was previously claimed, closed, and reopened). + """ + audit = BoardAuditLog(tmp_path / "audit.db") + await audit.init() + s = ProjectTaskStore(tmp_path / "tasks.db", audit=audit) + await s.init() + try: + task = await s.create_task("prj-1", "Task", "alice") + await s.claim_task(task["id"], "worker-1") + await s.close_task(task["id"], "worker-1") + await s.reopen_task(task["id"], "alice") + ok = await s.quarantine_task(task["id"], "system") + assert ok is True + history = await audit.history(task["id"]) + quarantined = [h for h in history if h["event"] == "task.quarantined"] + assert len(quarantined) == 1 + assert quarantined[0]["from_status"] == "open" + finally: + await s.close() + await audit.close() diff --git a/tinyagentos/projects/task_store.py b/tinyagentos/projects/task_store.py index b71507267..0eed35546 100644 --- a/tinyagentos/projects/task_store.py +++ b/tinyagentos/projects/task_store.py @@ -404,8 +404,9 @@ async def reopen_task(self, task_id: str, reopened_by: str) -> bool: now = time.time() cursor = await self._db.execute( """UPDATE project_tasks - SET status = 'open', closed_by = NULL, closed_at = NULL, close_reason = NULL, - claimed_by = NULL, claimed_at = NULL, updated_at = ? + SET claimed_by = NULL, claimed_at = NULL, status = 'open', + closed_by = NULL, closed_at = NULL, close_reason = NULL, + updated_at = ? WHERE id = ? AND status = 'closed'""", (now, task_id), ) From 3083334107380f2a21713d50863c2ffc9a45f6e8 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Mon, 10 Aug 2026 08:09:43 +0000 Subject: [PATCH 26/56] fix(chat): preserve blocks on oversize export, flatten non-text blocks, exclude non-complete messages, fix tie causality Card tsk-yfy5j5 review found 4 blockers in the chat exporter: - Oversized messages had their blocks destroyed instead of preserved. The full original envelope (blocks intact) is now written through file_writer, and the emitted body keeps the flattened text (truncated if needed) plus a note carrying the ref. - Non-text blocks (images, files, etc.) with no usable text raised ChatExportError and bricked the whole channel export. flatten_body now renders them as a descriptive placeholder instead, and drops the content-field fallback that masked empty-body detection. - Messages with state 'streaming' or 'error' (in-flight/failed, not authentic history) were exported alongside complete ones; they are now excluded the same way soft-deleted messages are. - The (created_at, id) sort could place a reply before its parent on a timestamp tie since ids are random. A same-timestamp causality pass now orders parents before their repliers. Also documents the intentionally dropped fields in the module docstring and adds a regression test asserting they never leak into the envelope. --- tests/test_chat_exporter.py | 203 ++++++++++++++++++++++++++++-- tinyagentos/chat/chat_exporter.py | 128 ++++++++++++++++--- 2 files changed, 304 insertions(+), 27 deletions(-) diff --git a/tests/test_chat_exporter.py b/tests/test_chat_exporter.py index f5f1a3f17..e8ed361ca 100644 --- a/tests/test_chat_exporter.py +++ b/tests/test_chat_exporter.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import re import time from pathlib import Path @@ -63,12 +64,15 @@ async def test_flatten_text_blocks(): @pytest.mark.asyncio async def test_flatten_skips_empty_text(): + # Updated for card rule 3: a non-text block (image) no longer vanishes + # silently — it flattens to a descriptive placeholder. An empty-text + # text-type block still contributes nothing. blocks = [ {"type": "paragraph", "text": "Hello"}, {"type": "image", "url": "http://x/img.png"}, {"type": "paragraph", "text": ""}, ] - assert flatten_body(blocks) == "Hello" + assert flatten_body(blocks) == "Hello\n[image: http://x/img.png]" # --------------------------------------------------------------------------- @@ -183,13 +187,19 @@ async def test_reexport_produces_byte_identical_output(store, tmp_path): @pytest.mark.asyncio async def test_oversized_content_becomes_ref(store, tmp_path): + # Updated for card rule 5: the oversized envelope's blocks are no + # longer destroyed. The full original envelope (blocks intact) is + # written through file_writer, and the emitted body keeps the + # (possibly truncated) flattened text plus a note carrying the ref, + # rather than the body being replaced by the bare ref. big_text = "x" * 100_000 + blocks = [{"type": "paragraph", "text": big_text}] await store.send_message( channel_id="ch1", author_id="user1", author_type="user", content=big_text, - content_blocks=[{"type": "paragraph", "text": big_text}], + content_blocks=blocks, ) writer = _make_file_writer(tmp_path) @@ -202,10 +212,13 @@ async def test_oversized_content_becomes_ref(store, tmp_path): assert len(batch) == 1 env = batch[0] assert env["blocks"] == [] - assert "chat-export/" in env["body"] - assert env["body"].endswith(".txt") - written = (tmp_path / env["body"]).read_bytes() - assert written.decode("utf-8") == big_text + ref_match = re.search(r"chat-export/[^\s\]]+\.txt", env["body"]) + assert ref_match, f"no ref found in body: {env['body']!r}" + assert env["body"].endswith(f"[oversized content exported to: {ref_match.group(0)}]") + written = (tmp_path / ref_match.group(0)).read_bytes() + full_envelope = json.loads(written.decode("utf-8")) + assert full_envelope["blocks"] == blocks + assert full_envelope["body"] == big_text @pytest.mark.asyncio @@ -397,6 +410,49 @@ async def test_custom_source_identifier(store): assert batch[0]["source"] == "my-custom-source" +@pytest.mark.asyncio +async def test_dropped_fields_are_not_exported(store): + """Card rule 5 (documented drops): a message carrying content_type, + embeds, components, attachments, reactions, metadata, edited_at, + pinned, ephemeral, expires_at, and a non-default author_type still + exports cleanly, with the envelope carrying only the agreed fields.""" + msg = await store.send_message( + channel_id="ch1", + author_id="user1", + author_type="agent", + content="hello", + content_type="markdown", + content_blocks=[{"type": "paragraph", "text": "hello"}], + embeds=[{"kind": "link"}], + components=[{"kind": "button"}], + attachments=[{"filename": "x.png"}], + metadata={"secret": "do-not-export"}, + expires_at=time.time() + 1000, + ) + await store.pin_message("ch1", msg["id"], pinned_by="user1") + await store.edit_message(msg["id"], "hello edited") + await store.add_reaction(msg["id"], "\U0001F44D", "user1") + + exporter = ChatExporter( + message_store=store, + identity_map=_identity_map(["user1"]), + ) + batch = await exporter.export_channel("ch1") + assert len(batch) == 1 + env = batch[0] + allowed_keys = { + "from", "thread", "body", "blocks", "ts", "source", "source_id", + "reply_to", + } + assert set(env.keys()) <= allowed_keys + for dropped in ( + "content_type", "embeds", "components", "attachments", "reactions", + "metadata", "edited_at", "pinned", "ephemeral", "expires_at", + "author_type", + ): + assert dropped not in env + + @pytest.mark.asyncio async def test_empty_body_when_no_content(store): await store.send_message( @@ -433,8 +489,133 @@ async def test_file_writer_receives_utf8_body(store, tmp_path): file_writer=writer, ) batch = await exporter.export_channel("ch1") - ref = batch[0]["body"] - assert ref.startswith("chat-export/") - assert ref.endswith(".txt") - written = (tmp_path / ref).read_bytes() - assert written.decode("utf-8") == text + body = batch[0]["body"] + ref_match = re.search(r"chat-export/[^\s\]]+\.txt", body) + assert ref_match, f"no ref found in body: {body!r}" + written = (tmp_path / ref_match.group(0)).read_bytes() + full_envelope = json.loads(written.decode("utf-8")) + assert full_envelope["body"] == text + + +# --------------------------------------------------------------------------- +# RED-FIRST proof tests for fix batch (card tsk-yfy5j5). +# Each test below reproduces one of the 4 blockers found in review; they are +# written and run against the UNFIXED source first (captured in +# REDPROOF.txt), then the source is fixed until these go green. +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_oversize_message_preserves_blocks_via_ref(store, tmp_path): + """Card rule 5: an oversized message must not silently drop its blocks. + The full original envelope (blocks intact) is written through + file_writer, and the emitted body carries a reference to it.""" + big_text = "x" * 100_000 + blocks = [ + {"type": "paragraph", "text": big_text}, + {"type": "image", "url": "http://x/big.png"}, + ] + await store.send_message( + channel_id="ch1", + author_id="user1", + author_type="user", + content=big_text, + content_blocks=blocks, + ) + writer = _make_file_writer(tmp_path) + exporter = ChatExporter( + message_store=store, + identity_map=_identity_map(["user1"]), + file_writer=writer, + ) + batch = await exporter.export_channel("ch1") + env = batch[0] + assert env["blocks"] == [] + ref_match = re.search(r"chat-export/[^\s\]]+\.txt", env["body"]) + assert ref_match, f"no ref found in body: {env['body']!r}" + written = (tmp_path / ref_match.group(0)).read_text() + full_envelope = json.loads(written) + assert full_envelope["blocks"] == blocks + assert full_envelope["body"] == flatten_body(blocks) + + +@pytest.mark.asyncio +async def test_image_only_message_exports_with_placeholder_body(store): + """Card rule 3: a non-text (e.g. image) block must flatten to a + descriptive placeholder, never brick the whole channel export.""" + await store.send_message( + channel_id="ch1", + author_id="user1", + author_type="user", + content="", + content_blocks=[{"type": "image", "url": "http://x/img.png"}], + ) + exporter = ChatExporter( + message_store=store, + identity_map=_identity_map(["user1"]), + ) + batch = await exporter.export_channel("ch1") + assert len(batch) == 1 + assert batch[0]["body"] == "[image: http://x/img.png]" + + +@pytest.mark.asyncio +async def test_streaming_message_excluded_from_export(store): + """A message whose state is 'streaming' (or 'error') is not authentic + history and must be excluded, the same way deleted messages are.""" + await store.send_message( + channel_id="ch1", + author_id="user1", + author_type="agent", + content="", + content_blocks=[{"type": "paragraph", "text": "partial"}], + state="streaming", + ) + await store.send_message( + channel_id="ch1", + author_id="user1", + author_type="user", + content="done", + content_blocks=[{"type": "paragraph", "text": "done"}], + state="complete", + ) + exporter = ChatExporter( + message_store=store, + identity_map=_identity_map(["user1"]), + ) + batch = await exporter.export_channel("ch1") + assert len(batch) == 1 + assert batch[0]["body"] == "done" + + +@pytest.mark.asyncio +async def test_same_timestamp_reply_sorts_after_parent(store): + """Card rule 7: sort key (created_at, id) with random ids can put a + reply before its parent on a timestamp tie. Ids are chosen so the + naive sort gets it wrong (\"a-reply\" < \"z-parent\" lexically).""" + ts = time.time() + await store.ensure_message({ + "id": "z-parent", + "channel_id": "ch1", + "author_id": "user1", + "author_type": "user", + "content": "parent", + "content_blocks": [{"type": "paragraph", "text": "parent"}], + "created_at": ts, + }) + await store.ensure_message({ + "id": "a-reply", + "channel_id": "ch1", + "thread_id": "z-parent", + "author_id": "user1", + "author_type": "user", + "content": "reply", + "content_blocks": [{"type": "paragraph", "text": "reply"}], + "created_at": ts, + }) + exporter = ChatExporter( + message_store=store, + identity_map=_identity_map(["user1"]), + ) + batch = await exporter.export_channel("ch1") + ids = [e["source_id"] for e in batch] + assert ids.index("z-parent") < ids.index("a-reply") diff --git a/tinyagentos/chat/chat_exporter.py b/tinyagentos/chat/chat_exporter.py index b89cefc8c..7b9ab9c29 100644 --- a/tinyagentos/chat/chat_exporter.py +++ b/tinyagentos/chat/chat_exporter.py @@ -1,7 +1,18 @@ +"""Chat message exporter: transforms ChatMessageStore rows into A2A bus +import-batch envelopes. + +DROPPED FIELDS: the following ``chat_messages`` columns (and the +``chat_attachments`` table) are intentionally NOT carried into the export +envelope — they live outside the agreed bus envelope by design: +content_type, embeds, components, attachments (+ chat_attachments table), +reactions, metadata, edited_at, pinned, ephemeral, expires_at, author_type. +Only ``from``, ``thread``, ``body``, ``blocks``, ``ts``, ``source``, +``source_id``, and (when applicable) ``reply_to`` are exported. +""" + from __future__ import annotations import json -from pathlib import Path from typing import Any, Awaitable, Callable _MAX_MESSAGE_BYTES = 64 * 1024 @@ -13,17 +24,35 @@ class ChatExportError(Exception): def flatten_body(blocks: list[dict] | None) -> str: - """Flatten content blocks to plain text.""" + """Flatten content blocks to plain text. + + Blocks carrying a ``text`` key flatten as text (empty text contributes + nothing, same as before). Blocks without a ``text`` key (images, files, + and other non-text blocks) flatten to a descriptive placeholder built + from their type plus the most useful identifying field present, so a + non-text block never silently disappears or bricks the export. + """ if not blocks: return "" parts = [] for block in blocks: if isinstance(block, dict): - text = block.get("text") or block.get("content") or "" - if text: - parts.append(str(text)) + if "text" in block: + text = block.get("text") or "" + if text: + parts.append(str(text)) + continue + block_type = block.get("type") or "block" + identifier = ( + block.get("url") or block.get("name") or block.get("filename") + ) + if identifier: + parts.append(f"[{block_type}: {identifier}]") + else: + parts.append(f"[{block_type} block]") elif isinstance(block, str): - parts.append(block) + if block: + parts.append(block) return "\n".join(parts) @@ -31,6 +60,50 @@ def _serialized_size(envelope: dict) -> int: return len(json.dumps(envelope, ensure_ascii=False).encode("utf-8")) +def _causal_tiebreak(group: list[dict]) -> list[dict]: + """Stable topological sort of a same-timestamp group of messages: a + message replying (via ``thread_id``) to another message in the same + group is ordered after that parent. Messages with no such relationship + keep their original (already created_at/id sorted) relative order.""" + ids = {m.get("id") for m in group} + indegree = {m.get("id"): 0 for m in group} + children: dict[Any, list[dict]] = {m.get("id"): [] for m in group} + for m in group: + parent_id = m.get("thread_id") + if parent_id in ids: + children[parent_id].append(m) + indegree[m.get("id")] += 1 + + ready = [m for m in group if indegree[m.get("id")] == 0] + ordered: list[dict] = [] + while ready: + m = ready.pop(0) + ordered.append(m) + for child in children[m.get("id")]: + indegree[child.get("id")] -= 1 + if indegree[child.get("id")] == 0: + ready.append(child) + return ordered + + +def _sort_with_causality(messages: list[dict]) -> list[dict]: + """Sort by (created_at, id) then run a same-timestamp causality pass so + a reply never precedes its parent within an equal-timestamp group.""" + messages = sorted( + messages, key=lambda m: (m.get("created_at") or 0.0, m.get("id") or "") + ) + result: list[dict] = [] + i, n = 0, len(messages) + while i < n: + j = i + ts = messages[i].get("created_at") or 0.0 + while j < n and (messages[j].get("created_at") or 0.0) == ts: + j += 1 + result.extend(_causal_tiebreak(messages[i:j])) + i = j + return result + + class ChatExporter: """Export chat messages to A2A bus import batches. @@ -56,17 +129,21 @@ def __init__( self._file_writer = file_writer async def export_channel(self, channel_id: str) -> list[dict]: - """Export all non-deleted messages from a channel. + """Export all non-deleted, complete messages from a channel. - Returns envelopes ordered by (created_at ASC, id ASC) for - deterministic, reproducible output. Raises ChatExportError if any - author_id is unmapped. + Returns envelopes ordered by (created_at ASC, id ASC), with a + same-timestamp causality pass so a reply never precedes its parent, + for deterministic, reproducible output. Raises ChatExportError if + any author_id is unmapped. """ messages = await self._msg_store.get_all_messages_for_channel(channel_id) - messages = [m for m in messages if m.get("deleted_at") is None] - messages.sort( - key=lambda m: (m.get("created_at") or 0.0, m.get("id") or "") - ) + messages = [ + m + for m in messages + if m.get("deleted_at") is None + and m.get("state") in (None, "complete") + ] + messages = _sort_with_causality(messages) batch: list[dict] = [] exported_ids: set[str] = set() @@ -128,8 +205,27 @@ async def _transform_message(self, msg: dict) -> dict: f"message {source_id!r} exceeds {self._max_message_bytes} bytes " f"and no file_writer configured" ) - ref = await self._file_writer(source_id, body.encode("utf-8")) - envelope["body"] = ref + # Preserve the full original envelope (blocks intact) out-of-band + # rather than destroying it — oversize is usually caused by the + # blocks that would otherwise be dropped. + full_bytes = json.dumps(envelope, ensure_ascii=False).encode("utf-8") + ref = await self._file_writer(source_id, full_bytes) + + emitted_body = body + body_bytes = emitted_body.encode("utf-8") + if len(body_bytes) > self._max_message_bytes: + truncate_note = "... [truncated]" + budget = max( + self._max_message_bytes - len(truncate_note.encode("utf-8")), 0 + ) + emitted_body = ( + body_bytes[:budget].decode("utf-8", errors="ignore") + + truncate_note + ) + + envelope["body"] = ( + f"{emitted_body}\n[oversized content exported to: {ref}]" + ) envelope["blocks"] = [] return envelope From 99998819e968040f01375c4e5f1f722e04732940 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Mon, 10 Aug 2026 08:16:10 +0000 Subject: [PATCH 27/56] fix(taos-agent): queued messages survive turn errors; supervise the drive task Adversarial-review findings on the AgentLoop wiring: - The sink's early _DONE on the error path stranded the queued-message frames _drive's finally emits behind it in a queue nobody read - a message queued during a turn that then errored (the common model-proxy failure) silently evaporated, contradicting the queued-notice promise. The generator now drains the settled queue after the early _DONE and yields the leftovers before the closing done; a client disconnect skips the yields naturally (code after finally) and logs the drop instead. - Bare asyncio.create_task(_drive()) relied on the await-chain against asyncio's weak-ref task contract; a GC'd drive task would wedge the desktop loop in WORKING forever. Supervised via _create_supervised_task when app.state._background_tasks exists (same rationale as _acp_tasks). - Queued-notice copy no longer oversells (the message lands in the holder stream's tail; it is not driven as its own turn). - Router drain comment described an impossible interleaving; it now states the real mechanism (atomic drain, holder re-drains). Red-first: the new test fails on the unfixed route, 71 pass with the fix. --- tests/test_taos_agent_chat.py | 62 ++++++++++++++++++++++++++++++++ tinyagentos/agent_chat_router.py | 9 +++-- tinyagentos/routes/taos_agent.py | 37 +++++++++++++++++-- 3 files changed, 103 insertions(+), 5 deletions(-) diff --git a/tests/test_taos_agent_chat.py b/tests/test_taos_agent_chat.py index 0568b7e24..2d5a51b9f 100644 --- a/tests/test_taos_agent_chat.py +++ b/tests/test_taos_agent_chat.py @@ -296,6 +296,68 @@ async def close(self): assert items[-1] == {"done": True} +@pytest.mark.asyncio +async def test_chat_queued_message_survives_turn_error(client, app, monkeypatch): + """A message queued mid-turn is surfaced even when the turn ERRORS. + + The sink's error path enqueues _DONE early, which used to strand the + queued-message frames _drive's finally emits behind it in a queue nobody + read - the queued user's message silently evaporated on the most common + failure (a model-proxy error). The generator now drains the settled + queue after the early _DONE. + """ + await client.patch("/api/taos-agent/settings", json={"model": "gpt-4o"}) + app.state.llm_proxy = _make_mock_proxy(running=True) + app.state.taos_opencode_password = "testpw" + app.state.taos_opencode_session_id = None + + server = _fake_server() + + async def fake_ensure_server(state, model): + return server + + monkeypatch.setattr( + "tinyagentos.routes.taos_agent.ensure_taos_opencode_server", + fake_ensure_server, + ) + + class _QueueThenErrorAdapter: + def __init__(self, cfg, sink): + self._sink = sink + self.session_id = None + + async def ensure_session(self): + self.session_id = "ses_qerr" + + async def prompt(self, text, trace_id=None, attachments=None): + # A second user message lands while this turn is in flight... + loop = app.state.taos_agent_loop + from tinyagentos.agent_loop import LoopAction + action = await loop.handle_message("urgent follow-up", msg_id="q1") + assert action is LoopAction.QUEUED + # ...and then the turn fails (the common model-proxy shape). + self._sink({"kind": "error", "error": "proxy exploded"}) + + async def close(self): + pass + + monkeypatch.setattr( + "tinyagentos.routes.taos_agent.OpenCodeAdapter", + _QueueThenErrorAdapter, + ) + + resp = await client.post( + "/api/taos-agent/chat", + json={"messages": [{"role": "user", "content": "Hi"}]}, + ) + assert resp.status_code == 200 + assert "urgent follow-up" in resp.text + items = _parse_ndjson(resp.text) + assert items[-1] == {"done": True} + # And the loop is back to IDLE - the failed turn must not wedge it. + assert app.state.taos_agent_loop.state.value == "idle" + + # --------------------------------------------------------------------------- # ensure_taos_opencode_server: key minting and master-key fallback # --------------------------------------------------------------------------- diff --git a/tinyagentos/agent_chat_router.py b/tinyagentos/agent_chat_router.py index c555df835..c4ae21670 100644 --- a/tinyagentos/agent_chat_router.py +++ b/tinyagentos/agent_chat_router.py @@ -95,8 +95,13 @@ async def _run_acp_turn( followup = await loop.handle_message(m.content, msg_id=m.id) if followup is LoopAction.IMMEDIATE: pending.append((m.content, m.id)) - # QUEUED here means another concurrent caller grabbed the - # turn — it will drive this message at its own safe point. + # QUEUED here means this holder already restarted the turn + # with an earlier drained message (handle_message and the + # drain are atomic - the lock is never held across an await, + # so no other caller can interleave). The message sits in the + # loop's queue and this holder's next reach_safe_point + # re-drains it; WORKING always implies the current holder + # reaches another safe point. def dispatch(self, message: dict, channel: dict) -> None: """Fire-and-forget entry point. Runs routing in a supervised background task.""" diff --git a/tinyagentos/routes/taos_agent.py b/tinyagentos/routes/taos_agent.py index 3515bfd5d..bdd9c62c9 100644 --- a/tinyagentos/routes/taos_agent.py +++ b/tinyagentos/routes/taos_agent.py @@ -27,6 +27,7 @@ import logging import mimetypes import re +import sys import uuid from pathlib import Path @@ -38,6 +39,7 @@ from tinyagentos.agent_loop import AgentLoop, LoopAction from tinyagentos.opencode_runtime import OpenCodeBinaryNotFoundError from tinyagentos.taos_agent_runtime import ensure_taos_opencode_server +from tinyagentos.task_utils import _create_supervised_task logger = logging.getLogger(__name__) router = APIRouter() @@ -496,8 +498,9 @@ async def _queued_stream(): yield json.dumps({ "delta": ( "[The taOS agent is still working on a previous message. " - "Your message has been queued and will be surfaced to the " - "agent when the current turn completes.]" + "Your message has been queued and will appear at the end " + "of the current response; the agent does not act on it as " + "its own turn, so resend it if you need a full answer.]" ), }) + "\n" yield json.dumps({"done": True}) + "\n" @@ -533,10 +536,19 @@ async def _drive() -> None: }) queue.put_nowait(_DONE) - drive_task = asyncio.create_task(_drive()) + # Supervised: _drive's finally is the ONLY thing standing between the + # agent loop's WORKING and IDLE states, so a GC'd drive task (asyncio + # keeps only weak refs) would wedge the desktop agent in WORKING forever. + # Same rationale as AgentChatRouter._acp_tasks. + _drive_tasks = getattr(app_state, "_background_tasks", None) + if _drive_tasks is None: + drive_task = asyncio.create_task(_drive()) + else: + drive_task = _create_supervised_task(_drive(), _drive_tasks) async def _generate(): content_frame_yielded = False + leftovers: list = [] try: while True: item = await queue.get() @@ -560,6 +572,25 @@ async def _generate(): exc = drive_task.exception() if exc is not None: logger.error("taos-agent: drive task raised %r", exc) + # The sink's early _DONE on the error path strands anything behind + # it in the queue - including the queued-message frames _drive's + # finally emits. Collect them here (drive_task has settled, so the + # queue is final); they are yielded AFTER this finally, which a + # client disconnect (GeneratorExit) naturally skips - log those so + # a dropped queued message is at least visible. + while not queue.empty(): + item = queue.get_nowait() + if item is not _DONE: + leftovers.append(item) + if leftovers and isinstance(sys.exc_info()[1], GeneratorExit): + logger.warning( + "taos-agent: client disconnected with %d undelivered frame(s): %r", + len(leftovers), leftovers, + ) + for item in leftovers: + if "error" not in item: + content_frame_yielded = True + yield json.dumps(item) + "\n" if not content_frame_yielded: yield json.dumps({ "error": ( From 1a0461e1e8d12926669eeb50a3e16abbb43be534 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Mon, 10 Aug 2026 08:17:46 +0000 Subject: [PATCH 28/56] revert the no-op reopen_task edit: dev already clears claimed_by The card's premise was false (a truncated read of reopen_task missed the line that already clears claimed_by/claimed_at) and this PR's own reclaim test passes against unmodified dev - the SQL change was a pure column reorder. What remains true and stays: the regression tests pinning the existing reopen/reclaim/audit behavior, the open-path quarantine audit assertion, and the claimable-label doc clarification. --- tinyagentos/projects/task_store.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tinyagentos/projects/task_store.py b/tinyagentos/projects/task_store.py index 0eed35546..b71507267 100644 --- a/tinyagentos/projects/task_store.py +++ b/tinyagentos/projects/task_store.py @@ -404,9 +404,8 @@ async def reopen_task(self, task_id: str, reopened_by: str) -> bool: now = time.time() cursor = await self._db.execute( """UPDATE project_tasks - SET claimed_by = NULL, claimed_at = NULL, status = 'open', - closed_by = NULL, closed_at = NULL, close_reason = NULL, - updated_at = ? + SET status = 'open', closed_by = NULL, closed_at = NULL, close_reason = NULL, + claimed_by = NULL, claimed_at = NULL, updated_at = ? WHERE id = ? AND status = 'closed'""", (now, task_id), ) From 7f1544ea2ec2b63a3ba4081aac68c8709bea496d Mon Sep 17 00:00:00 2001 From: jaylfc Date: Mon, 10 Aug 2026 08:29:37 +0000 Subject: [PATCH 29/56] fix(chat-exporter): never drop thread_id-cycle rows; budget oversize body against the serialized envelope Two post-merge findings on #2346 (kilo flagged both; lead confirmed each against the code and red-proved): - _causal_tiebreak returned only Kahn-ordered rows, so a thread_id cycle in a same-timestamp group (crafted self/mutual reference - the store does not validate) silently vanished from the export. Cycle members now append in their original order; a migration tool must never drop a row. - Oversize truncation capped the raw body bytes only, then appended the ref note on top with no accounting for the envelope's other fields, so a near-cap body still serialized past 64KB and the bus would reject it. The budget now derives from the serialized envelope overhead. Red-first: both tests fail on the unfixed code, 25 pass with the fixes. --- tests/test_chat_exporter.py | 49 +++++++++++++++++++++++++++++++ tinyagentos/chat/chat_exporter.py | 35 ++++++++++++++++------ 2 files changed, 75 insertions(+), 9 deletions(-) diff --git a/tests/test_chat_exporter.py b/tests/test_chat_exporter.py index e8ed361ca..715242808 100644 --- a/tests/test_chat_exporter.py +++ b/tests/test_chat_exporter.py @@ -619,3 +619,52 @@ async def test_same_timestamp_reply_sorts_after_parent(store): batch = await exporter.export_channel("ch1") ids = [e["source_id"] for e in batch] assert ids.index("z-parent") < ids.index("a-reply") + + +@pytest.mark.asyncio +async def test_thread_id_cycle_messages_are_not_dropped(store): + """A thread_id cycle within a same-timestamp group (the store does not + validate against crafted self/mutual references) has no topological + order — but a migration tool must never silently drop the rows.""" + ts = 1700000000.0 + await store.ensure_message({ + "id": "msg-a", "channel_id": "ch1", "author_id": "user1", + "author_type": "user", "content": "a", + "content_blocks": [{"type": "paragraph", "text": "a"}], + "created_at": ts, "thread_id": "msg-b", + }) + await store.ensure_message({ + "id": "msg-b", "channel_id": "ch1", "author_id": "user1", + "author_type": "user", "content": "b", + "content_blocks": [{"type": "paragraph", "text": "b"}], + "created_at": ts, "thread_id": "msg-a", + }) + exporter = ChatExporter( + message_store=store, identity_map=_identity_map(["user1"]), + ) + batch = await exporter.export_channel("ch1") + assert {e["source_id"] for e in batch} == {"msg-a", "msg-b"} + + +@pytest.mark.asyncio +async def test_oversize_envelope_never_exceeds_limit_serialized(store, tmp_path): + """The 64KB limit applies to the SERIALIZED envelope: body truncation + must budget for the ref note and the envelope's other fields, not just + the raw body bytes.""" + big_text = "x" * 100_000 + await store.send_message( + channel_id="ch1", author_id="user1", author_type="user", + content=big_text, + content_blocks=[{"type": "paragraph", "text": big_text}], + ) + exporter = ChatExporter( + message_store=store, + identity_map=_identity_map(["user1"]), + file_writer=_make_file_writer(tmp_path), + ) + batch = await exporter.export_channel("ch1") + env = batch[0] + serialized = json.dumps(env, ensure_ascii=False).encode("utf-8") + assert len(serialized) <= 64 * 1024, ( + f"serialized envelope is {len(serialized)} bytes" + ) diff --git a/tinyagentos/chat/chat_exporter.py b/tinyagentos/chat/chat_exporter.py index 7b9ab9c29..c24c4577e 100644 --- a/tinyagentos/chat/chat_exporter.py +++ b/tinyagentos/chat/chat_exporter.py @@ -83,6 +83,13 @@ def _causal_tiebreak(group: list[dict]) -> list[dict]: indegree[child.get("id")] -= 1 if indegree[child.get("id")] == 0: ready.append(child) + if len(ordered) < len(group): + # thread_id cycle (e.g. a crafted self-reference - the store does not + # validate). No topological order exists for the cycle members, but a + # migration tool must never silently drop a row: append them in their + # original (created_at, id) order. + seen = {id(m) for m in ordered} + ordered.extend(m for m in group if id(m) not in seen) return ordered @@ -211,22 +218,32 @@ async def _transform_message(self, msg: dict) -> dict: full_bytes = json.dumps(envelope, ensure_ascii=False).encode("utf-8") ref = await self._file_writer(source_id, full_bytes) + # Budget the body against the SERIALIZED envelope size: the ref + # note and the envelope's other fields (from/thread/ts/source/...) + # all count toward the bus's per-message limit, so truncating the + # body to the raw limit alone can still emit an oversized envelope + # the bus rejects. + ref_note = f"\n[oversized content exported to: {ref}]" + envelope["blocks"] = [] + envelope["body"] = "" + overhead = len( + json.dumps(envelope, ensure_ascii=False).encode("utf-8") + ) + len(json.dumps(ref_note, ensure_ascii=False).encode("utf-8")) + truncate_note = "... [truncated]" + body_bytes = body.encode("utf-8") emitted_body = body - body_bytes = emitted_body.encode("utf-8") - if len(body_bytes) > self._max_message_bytes: - truncate_note = "... [truncated]" + if overhead + len(body_bytes) > self._max_message_bytes: budget = max( - self._max_message_bytes - len(truncate_note.encode("utf-8")), 0 + self._max_message_bytes + - overhead + - len(truncate_note.encode("utf-8")), + 0, ) emitted_body = ( body_bytes[:budget].decode("utf-8", errors="ignore") + truncate_note ) - - envelope["body"] = ( - f"{emitted_body}\n[oversized content exported to: {ref}]" - ) - envelope["blocks"] = [] + envelope["body"] = f"{emitted_body}{ref_note}" return envelope From d26d2e9a12364c2896129972db0c5301d7ca0a7a Mon Sep 17 00:00:00 2001 From: jaylfc Date: Mon, 10 Aug 2026 08:32:40 +0000 Subject: [PATCH 30/56] test: add vitest coverage for GuidesApp Tests cover mount fetch of tiers and use-cases, option rendering, button state, recommendations fetch and rendering, error handling, and empty state. --- desktop/src/apps/GuidesApp.test.tsx | 203 ++++++++++++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 desktop/src/apps/GuidesApp.test.tsx diff --git a/desktop/src/apps/GuidesApp.test.tsx b/desktop/src/apps/GuidesApp.test.tsx new file mode 100644 index 000000000..7d17c8311 --- /dev/null +++ b/desktop/src/apps/GuidesApp.test.tsx @@ -0,0 +1,203 @@ +import { render, screen, act, waitFor, fireEvent } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { GuidesApp } from "./GuidesApp"; + +function mockFetch( + responses: Record, +) { + return vi.fn().mockImplementation((input: string) => { + const hit = responses[input] ?? responses["*"]; + if (!hit) throw new Error(`Unmocked fetch: ${input}`); + return Promise.resolve({ + ok: hit.ok, + status: hit.status ?? (hit.ok ? 200 : 500), + json: () => Promise.resolve(hit.body), + }); + }); +} + +async function flush() { + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); +} + +const tiers = { + "tier-1": { label: "Small", description: "Single node", icon: "cpu" }, + "tier-2": { label: "Large", description: "Cluster", icon: "server" }, +}; + +const useCases = { + "uc-1": { label: "Coding", description: "AI coding", icon: "code" }, + "uc-2": { label: "Research", description: "Deep research", icon: "research" }, +}; + +const recommendationsResponse = { + hardware: "tier-1", + use_case: "uc-1", + recommendations: [ + { model: "Model A", reason: "Best for coding", note: "Very fast" }, + { model: "Model B", reason: "Most capable" }, + ], +}; + +describe("GuidesApp", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("fetches tiers and use cases on mount", async () => { + const fetchMock = mockFetch({ + "/api/guides/tiers": { ok: true, body: { tiers } }, + "/api/guides/use-cases": { ok: true, body: { use_cases: useCases } }, + }); + vi.stubGlobal("fetch", fetchMock); + render(); + await flush(); + expect(fetchMock).toHaveBeenCalledWith("/api/guides/tiers"); + expect(fetchMock).toHaveBeenCalledWith("/api/guides/use-cases"); + }); + + it("renders the header, guideline banner, and initial prompt", async () => { + vi.stubGlobal( + "fetch", + mockFetch({ + "/api/guides/tiers": { ok: true, body: { tiers } }, + "/api/guides/use-cases": { ok: true, body: { use_cases: useCases } }, + }), + ); + render(); + await flush(); + + expect(screen.getByRole("heading", { name: "Model Guides" })).toBeTruthy(); + expect( + screen.getByText(/opinionated, curated recommendations/i), + ).toBeTruthy(); + expect( + screen.getByText(/select your hardware tier and use case above/i), + ).toBeTruthy(); + }); + + it("renders tier and use case options after metadata loads", async () => { + vi.stubGlobal( + "fetch", + mockFetch({ + "/api/guides/tiers": { ok: true, body: { tiers } }, + "/api/guides/use-cases": { ok: true, body: { use_cases: useCases } }, + }), + ); + render(); + await flush(); + + expect(screen.getByText(/single node/i)).toBeTruthy(); + expect(screen.getByText(/cluster/i)).toBeTruthy(); + expect(screen.getByText(/ai coding/i)).toBeTruthy(); + expect(screen.getByText(/deep research/i)).toBeTruthy(); + }); + + it("disables the Get Recommendations button until both selectors are set", async () => { + vi.stubGlobal( + "fetch", + mockFetch({ + "/api/guides/tiers": { ok: true, body: { tiers } }, + "/api/guides/use-cases": { ok: true, body: { use_cases: useCases } }, + }), + ); + render(); + await flush(); + + const button = screen.getByRole("button", { name: /get recommendations/i }); + expect(button).toBeDisabled(); + }); + + it("fetches recommendations and renders them when a tier and use case are selected", async () => { + const fetchMock = mockFetch({ + "/api/guides/tiers": { ok: true, body: { tiers } }, + "/api/guides/use-cases": { ok: true, body: { use_cases: useCases } }, + "/api/guides/recommendations?hardware=tier-1&use_case=uc-1": { + ok: true, + body: recommendationsResponse, + }, + }); + vi.stubGlobal("fetch", fetchMock); + render(); + await flush(); + + const [tierSelect, caseSelect] = screen.getAllByRole("combobox"); + fireEvent.change(tierSelect, { target: { value: "tier-1" } }); + fireEvent.change(caseSelect, { target: { value: "uc-1" } }); + await flush(); + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith( + "/api/guides/recommendations?hardware=tier-1&use_case=uc-1", + ); + }); + expect(screen.getByText("Model A")).toBeTruthy(); + expect(screen.getByText("Model B")).toBeTruthy(); + expect(screen.getByText(/best for coding/i)).toBeTruthy(); + expect(screen.getByText("Very fast")).toBeTruthy(); + expect(screen.getByText(/recommended for/i)).toBeTruthy(); + expect( + screen.getByRole("button", { name: /get recommendations/i }), + ).toBeEnabled(); + }); + + it("shows an error when the recommendations fetch fails", async () => { + vi.stubGlobal( + "fetch", + mockFetch({ + "/api/guides/tiers": { ok: true, body: { tiers } }, + "/api/guides/use-cases": { ok: true, body: { use_cases: useCases } }, + "/api/guides/recommendations?hardware=tier-1&use_case=uc-1": { + ok: false, + status: 500, + body: { detail: "Internal error" }, + }, + }), + ); + render(); + await flush(); + + const [tierSelect, caseSelect] = screen.getAllByRole("combobox"); + fireEvent.change(tierSelect, { target: { value: "tier-1" } }); + fireEvent.change(caseSelect, { target: { value: "uc-1" } }); + await flush(); + + await waitFor(() => { + expect(screen.getByText(/internal error/i)).toBeTruthy(); + }); + expect( + screen.queryByText(/no recommendations yet/i), + ).toBeNull(); + }); + + it("shows empty state when the recommendations list is empty", async () => { + vi.stubGlobal( + "fetch", + mockFetch({ + "/api/guides/tiers": { ok: true, body: { tiers } }, + "/api/guides/use-cases": { ok: true, body: { use_cases: useCases } }, + "/api/guides/recommendations?hardware=tier-1&use_case=uc-1": { + ok: true, + body: { + hardware: "tier-1", + use_case: "uc-1", + recommendations: [], + }, + }, + }), + ); + render(); + await flush(); + + const [tierSelect, caseSelect] = screen.getAllByRole("combobox"); + fireEvent.change(tierSelect, { target: { value: "tier-1" } }); + fireEvent.change(caseSelect, { target: { value: "uc-1" } }); + await flush(); + + await waitFor(() => { + expect(screen.getByText(/no recommendations yet/i)).toBeTruthy(); + }); + }); +}); From 1a0e1b35e9059a72f9bbe9149616865b69aac4fd Mon Sep 17 00:00:00 2001 From: jaylfc Date: Mon, 10 Aug 2026 08:44:30 +0000 Subject: [PATCH 31/56] Add editable handle field in Agents registry panel --- .../src/apps/agents/RegistryPanel.test.tsx | 256 ++++++++++++++++++ desktop/src/apps/agents/RegistryPanel.tsx | 119 +++++++- 2 files changed, 374 insertions(+), 1 deletion(-) diff --git a/desktop/src/apps/agents/RegistryPanel.test.tsx b/desktop/src/apps/agents/RegistryPanel.test.tsx index a3640e9fb..f9f27972c 100644 --- a/desktop/src/apps/agents/RegistryPanel.test.tsx +++ b/desktop/src/apps/agents/RegistryPanel.test.tsx @@ -353,3 +353,259 @@ describe("RegistryPanel collapsed retired", () => { expect(retiredPanel!).not.toHaveClass("hidden"); }, 10_000); }); + +/* ------------------------------------------------------------------ */ +/* Handle (alias) editing UI */ +/* ------------------------------------------------------------------ */ + +describe("RegistryPanel handle editing", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("shows the handle text and edit button for owner/admin", async () => { + const entryWithHandle: RegistryEntry = { + ...fakeEntry, + handle: "my-alias", + }; + vi.stubGlobal("fetch", makeFetch([entryWithHandle])); + + render(); + + const toggle = screen.getByRole("button", { name: /agent registry/i }); + await act(async () => { toggle.click(); }); + + await waitFor( + () => { + expect(screen.getByText("@my-alias")).toBeInTheDocument(); + expect(screen.getByTitle("Edit handle")).toBeInTheDocument(); + }, + { timeout: 3000 }, + ); + }); + + it("hides edit button for non-owner non-admin", async () => { + const entryWithHandle: RegistryEntry = { + ...fakeEntry, + handle: "my-alias", + user_id: "other-user", + }; + const mockFetch = vi.fn().mockImplementation((url: string) => { + if (url === "/auth/status") { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ user: { is_admin: false, id: "viewer-user" } }), + }); + } + if (url === "/api/agents/registry") { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve([entryWithHandle]), + }); + } + return Promise.resolve({ ok: true, json: () => Promise.resolve({}) }); + }); + vi.stubGlobal("fetch", mockFetch); + + render(); + + const toggle = screen.getByRole("button", { name: /agent registry/i }); + await act(async () => { toggle.click(); }); + + await waitFor( + () => { + expect(screen.getByText("@my-alias")).toBeInTheDocument(); + expect(screen.queryByTitle("Edit handle")).not.toBeInTheDocument(); + }, + { timeout: 3000 }, + ); + }); + + it("enters edit mode and saves handle via PATCH", async () => { + const entryWithHandle: RegistryEntry = { + ...fakeEntry, + handle: "old-alias", + }; + const mockFetch = vi.fn().mockImplementation((url: string, opts?: RequestInit) => { + if (url === "/auth/status") { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ user: { is_admin: true, id: "user-1" } }), + }); + } + if (url === "/api/agents/registry") { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve([entryWithHandle]), + }); + } + if (typeof url === "string" && url.includes("/api/agents/registry/") && opts?.method === "PATCH") { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ ...entryWithHandle, handle: "new-alias" }), + }); + } + return Promise.resolve({ ok: true, json: () => Promise.resolve({}) }); + }); + vi.stubGlobal("fetch", mockFetch); + + render(); + + const toggle = screen.getByRole("button", { name: /agent registry/i }); + await act(async () => { toggle.click(); }); + + await waitFor( + () => { + expect(screen.getByText("@old-alias")).toBeInTheDocument(); + }, + { timeout: 3000 }, + ); + + const editBtn = screen.getByTitle("Edit handle"); + await act(async () => { editBtn.click(); }); + + const input = screen.getByRole("textbox"); + expect(input).toHaveValue("old-alias"); + + await act(async () => { + const nativeInputValueSetter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + "value", + )!.set!; + nativeInputValueSetter.call(input, "new-alias"); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + + const saveBtn = screen.getByTitle("Save handle"); + await act(async () => { saveBtn.click(); }); + await act(async () => { await Promise.resolve(); }); + + const patchCalls = mockFetch.mock.calls.filter( + ([url, opts]) => typeof url === "string" && url.includes("/api/agents/registry/") && (opts as RequestInit)?.method === "PATCH", + ); + expect(patchCalls.length).toBeGreaterThanOrEqual(1); + const body = JSON.parse((patchCalls[0][1] as RequestInit).body as string); + expect(body.handle).toBe("new-alias"); + }); + + it("shows error message when PATCH fails", async () => { + const entryWithHandle: RegistryEntry = { + ...fakeEntry, + handle: "old-alias", + }; + const mockFetch = vi.fn().mockImplementation((url: string, opts?: RequestInit) => { + if (url === "/auth/status") { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ user: { is_admin: true, id: "user-1" } }), + }); + } + if (url === "/api/agents/registry") { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve([entryWithHandle]), + }); + } + if (typeof url === "string" && url.includes("/api/agents/registry/") && opts?.method === "PATCH") { + return Promise.resolve({ + ok: false, + status: 409, + json: () => Promise.resolve({ error: "handle is already owned by another active agent" }), + }); + } + return Promise.resolve({ ok: true, json: () => Promise.resolve({}) }); + }); + vi.stubGlobal("fetch", mockFetch); + + render(); + + const toggle = screen.getByRole("button", { name: /agent registry/i }); + await act(async () => { toggle.click(); }); + + await waitFor( + () => { + expect(screen.getByText("@old-alias")).toBeInTheDocument(); + }, + { timeout: 3000 }, + ); + + const editBtn = screen.getByTitle("Edit handle"); + await act(async () => { editBtn.click(); }); + + const input = screen.getByRole("textbox"); + await act(async () => { + const nativeInputValueSetter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + "value", + )!.set!; + nativeInputValueSetter.call(input, "taken-alias"); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + + const saveBtn = screen.getByTitle("Save handle"); + await act(async () => { saveBtn.click(); }); + await act(async () => { await Promise.resolve(); }); + + expect(screen.getByText("handle is already owned by another active agent")).toBeInTheDocument(); + }); + + it("cancel restores original handle and exits edit mode", async () => { + const entryWithHandle: RegistryEntry = { + ...fakeEntry, + handle: "original", + }; + const mockFetch = vi.fn().mockImplementation((url: string) => { + if (url === "/auth/status") { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ user: { is_admin: true, id: "user-1" } }), + }); + } + if (url === "/api/agents/registry") { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve([entryWithHandle]), + }); + } + return Promise.resolve({ ok: true, json: () => Promise.resolve({}) }); + }); + vi.stubGlobal("fetch", mockFetch); + + render(); + + const toggle = screen.getByRole("button", { name: /agent registry/i }); + await act(async () => { toggle.click(); }); + + await waitFor( + () => { + expect(screen.getByText("@original")).toBeInTheDocument(); + }, + { timeout: 3000 }, + ); + + const editBtn = screen.getByTitle("Edit handle"); + await act(async () => { editBtn.click(); }); + + const input = screen.getByRole("textbox"); + await act(async () => { + const nativeInputValueSetter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + "value", + )!.set!; + nativeInputValueSetter.call(input, "modified"); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + + const cancelBtn = screen.getByTitle("Cancel"); + await act(async () => { cancelBtn.click(); }); + + await waitFor( + () => { + expect(screen.getByText("@original")).toBeInTheDocument(); + expect(screen.queryByRole("textbox")).not.toBeInTheDocument(); + expect(screen.getByTitle("Edit handle")).toBeInTheDocument(); + }, + { timeout: 3000 }, + ); + }); +}); diff --git a/desktop/src/apps/agents/RegistryPanel.tsx b/desktop/src/apps/agents/RegistryPanel.tsx index 6dab7b794..bc4555833 100644 --- a/desktop/src/apps/agents/RegistryPanel.tsx +++ b/desktop/src/apps/agents/RegistryPanel.tsx @@ -13,8 +13,9 @@ import { ArrowRight, UserPlus, Archive, + Pencil, } from "lucide-react"; -import { Button, Card } from "@/components/ui"; +import { Button, Card, Input } from "@/components/ui"; import { projectsApi } from "@/lib/projects"; import { AssignAgentToProjectDialog } from "./AssignAgentToProjectDialog"; import { InviteAgentDialog } from "@/apps/ProjectsApp/InviteAgentDialog"; @@ -134,19 +135,49 @@ function RegistryEntryRow({ currentUserId, onAction, onAssign, + onPatchHandle, }: { entry: RegistryEntry; isAdmin: boolean; currentUserId: string; onAction: (id: string, action: "approve" | "reject" | "suspend" | "reactivate" | "revoke") => Promise; onAssign: (entry: RegistryEntry) => void; + onPatchHandle: (id: string, handle: string) => Promise; }) { const [busy, setBusy] = useState(false); const [err, setErr] = useState(null); + const [editingHandle, setEditingHandle] = useState(false); + const [handleDraft, setHandleDraft] = useState(entry.handle); + const [savingHandle, setSavingHandle] = useState(false); + const [handleErr, setHandleErr] = useState(null); const isOwner = entry.user_id === currentUserId; + const canEditHandle = isAdmin || isOwner; const canRevoke = (isAdmin || isOwner) && (entry.status === "active" || entry.status === "suspended"); const canAssign = (isAdmin || isOwner) && entry.status === "active"; + useEffect(() => { + setHandleDraft(entry.handle); + }, [entry.handle]); + + async function saveHandle() { + const trimmed = handleDraft.trim(); + if (trimmed === entry.handle) { + setEditingHandle(false); + setHandleErr(null); + return; + } + setSavingHandle(true); + setHandleErr(null); + try { + await onPatchHandle(entry.canonical_id, trimmed); + setEditingHandle(false); + } catch (e: unknown) { + setHandleErr(e instanceof Error ? e.message : String(e)); + } finally { + setSavingHandle(false); + } + } + async function act(action: "approve" | "reject" | "suspend" | "reactivate" | "revoke") { setBusy(true); setErr(null); @@ -191,6 +222,69 @@ function RegistryEntryRow({ )} +
+ + {entry.handle ? `@${entry.handle}` : "no handle"} + + {canEditHandle && ( + editingHandle ? ( + <> + setHandleDraft(e.target.value)} + className="h-7 text-xs w-40" + autoFocus + onKeyDown={(e) => { + if (e.key === "Enter") saveHandle(); + if (e.key === "Escape") { + setHandleDraft(entry.handle); + setEditingHandle(false); + setHandleErr(null); + } + }} + disabled={savingHandle} + /> + + + + ) : ( + + ) + )} + {handleErr && ( + {handleErr} + )} +
@@ -745,6 +839,26 @@ export function RegistryPanel() { }; }, [expanded, load]); + async function handlePatchHandle( + canonical_id: string, + handle: string, + ) { + const resp = await fetch( + `/api/agents/registry/${encodeURIComponent(canonical_id)}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ handle }), + credentials: "include", + }, + ); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + throw new Error((err as { error?: string }).error ?? `HTTP ${resp.status}`); + } + await load({ quiet: true }); + } + async function handleAction( canonical_id: string, action: "approve" | "reject" | "suspend" | "reactivate" | "revoke", @@ -822,6 +936,7 @@ export function RegistryPanel() { currentUserId={currentUserId} onAction={handleAction} onAssign={setAssignEntry} + onPatchHandle={handlePatchHandle} /> ))} @@ -841,6 +956,7 @@ export function RegistryPanel() { currentUserId={currentUserId} onAction={handleAction} onAssign={setAssignEntry} + onPatchHandle={handlePatchHandle} /> ))}
@@ -876,6 +992,7 @@ export function RegistryPanel() { currentUserId={currentUserId} onAction={handleAction} onAssign={setAssignEntry} + onPatchHandle={handlePatchHandle} /> ))} From 69ed439ace867724baab6dd3104171a518cc977d Mon Sep 17 00:00:00 2001 From: jaylfc Date: Mon, 10 Aug 2026 08:53:00 +0000 Subject: [PATCH 32/56] fix(agents): normalize @ in handle editing via stripAt; label the input Seeded internal agents store @-prefixed handles (@taOS-dev), so the new alias row rendered @@taOS-dev; a typed leading @ was also stored verbatim, making '@x' and 'x' distinct handles under the active-handle unique index. Route display and save through the existing stripAt helper (@ is bus addressing syntax, not part of the name) and give the edit input an accessible name. --- .../src/apps/agents/RegistryPanel.test.tsx | 86 +++++++++++++++++++ desktop/src/apps/agents/RegistryPanel.tsx | 15 ++-- 2 files changed, 94 insertions(+), 7 deletions(-) diff --git a/desktop/src/apps/agents/RegistryPanel.test.tsx b/desktop/src/apps/agents/RegistryPanel.test.tsx index f9f27972c..3aee191bf 100644 --- a/desktop/src/apps/agents/RegistryPanel.test.tsx +++ b/desktop/src/apps/agents/RegistryPanel.test.tsx @@ -608,4 +608,90 @@ describe("RegistryPanel handle editing", () => { { timeout: 3000 }, ); }); + + it("displays a stored @-prefixed handle with a single @ (internal seeds)", async () => { + const entryWithHandle: RegistryEntry = { + ...fakeEntry, + handle: "@taOS-dev", + }; + vi.stubGlobal("fetch", makeFetch([entryWithHandle])); + + render(); + + const toggle = screen.getByRole("button", { name: /agent registry/i }); + await act(async () => { toggle.click(); }); + + await waitFor( + () => { + expect(screen.getByText("@taOS-dev")).toBeInTheDocument(); + expect(screen.queryByText("@@taOS-dev")).not.toBeInTheDocument(); + }, + { timeout: 3000 }, + ); + }); + + it("strips a typed leading @ before PATCH (@ is bus syntax, not part of the name)", async () => { + const entryWithHandle: RegistryEntry = { + ...fakeEntry, + handle: "old-alias", + }; + const mockFetch = vi.fn().mockImplementation((url: string, opts?: RequestInit) => { + if (url === "/auth/status") { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ user: { is_admin: true, id: "user-1" } }), + }); + } + if (url === "/api/agents/registry") { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve([entryWithHandle]), + }); + } + if (typeof url === "string" && url.includes("/api/agents/registry/") && opts?.method === "PATCH") { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ ...entryWithHandle, handle: "new-alias" }), + }); + } + return Promise.resolve({ ok: true, json: () => Promise.resolve({}) }); + }); + vi.stubGlobal("fetch", mockFetch); + + render(); + + const toggle = screen.getByRole("button", { name: /agent registry/i }); + await act(async () => { toggle.click(); }); + + await waitFor( + () => { + expect(screen.getByText("@old-alias")).toBeInTheDocument(); + }, + { timeout: 3000 }, + ); + + const editBtn = screen.getByTitle("Edit handle"); + await act(async () => { editBtn.click(); }); + + const input = screen.getByRole("textbox"); + await act(async () => { + const nativeInputValueSetter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + "value", + )!.set!; + nativeInputValueSetter.call(input, "@new-alias"); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + + const saveBtn = screen.getByTitle("Save handle"); + await act(async () => { saveBtn.click(); }); + await act(async () => { await Promise.resolve(); }); + + const patchCalls = mockFetch.mock.calls.filter( + ([url, opts]) => typeof url === "string" && url.includes("/api/agents/registry/") && (opts as RequestInit)?.method === "PATCH", + ); + expect(patchCalls.length).toBe(1); + const body = JSON.parse((patchCalls[0][1] as RequestInit).body as string); + expect(body.handle).toBe("new-alias"); + }); }); diff --git a/desktop/src/apps/agents/RegistryPanel.tsx b/desktop/src/apps/agents/RegistryPanel.tsx index bc4555833..8b8a48b4f 100644 --- a/desktop/src/apps/agents/RegistryPanel.tsx +++ b/desktop/src/apps/agents/RegistryPanel.tsx @@ -147,7 +147,7 @@ function RegistryEntryRow({ const [busy, setBusy] = useState(false); const [err, setErr] = useState(null); const [editingHandle, setEditingHandle] = useState(false); - const [handleDraft, setHandleDraft] = useState(entry.handle); + const [handleDraft, setHandleDraft] = useState(stripAt(entry.handle)); const [savingHandle, setSavingHandle] = useState(false); const [handleErr, setHandleErr] = useState(null); const isOwner = entry.user_id === currentUserId; @@ -156,12 +156,12 @@ function RegistryEntryRow({ const canAssign = (isAdmin || isOwner) && entry.status === "active"; useEffect(() => { - setHandleDraft(entry.handle); + setHandleDraft(stripAt(entry.handle)); }, [entry.handle]); async function saveHandle() { - const trimmed = handleDraft.trim(); - if (trimmed === entry.handle) { + const trimmed = stripAt(handleDraft.trim()); + if (trimmed === stripAt(entry.handle)) { setEditingHandle(false); setHandleErr(null); return; @@ -224,20 +224,21 @@ function RegistryEntryRow({
- {entry.handle ? `@${entry.handle}` : "no handle"} + {entry.handle ? `@${stripAt(entry.handle)}` : "no handle"} {canEditHandle && ( editingHandle ? ( <> setHandleDraft(e.target.value)} className="h-7 text-xs w-40" autoFocus onKeyDown={(e) => { if (e.key === "Enter") saveHandle(); if (e.key === "Escape") { - setHandleDraft(entry.handle); + setHandleDraft(stripAt(entry.handle)); setEditingHandle(false); setHandleErr(null); } @@ -259,7 +260,7 @@ function RegistryEntryRow({ size="icon" className="h-7 w-7 hover:bg-zinc-500/15 hover:text-zinc-400" onClick={() => { - setHandleDraft(entry.handle); + setHandleDraft(stripAt(entry.handle)); setEditingHandle(false); setHandleErr(null); }} From 1444eb7a5f628eb2aa3c4d133f0a26c78eb77a22 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Mon, 10 Aug 2026 08:57:38 +0000 Subject: [PATCH 33/56] chore(deps): bump dompurify 3.4.13 + nanoid 5.1.16 (desktop, security) Clears both open Dependabot alerts on desktop/package-lock.json: dompurify GHSA-55q2-fjhq-7xh7 (moderate) and nanoid CVE-2026-67214 (high). Lock-only refresh; both new versions are inside the ranges package.json already declares, so no manifest change. Deliberately excludes the jsdom 29->30 half of Dependabot #2331: jsdom 30 dropped Node 20 support and spa-build pins Node 20, which is exactly why that PR is red. Docs-Reviewed: dependency lockfile bump only, no feature-code change; CHANGELOG entry added --- CHANGELOG.md | 8 ++++++++ desktop/package-lock.json | 16 ++++++++-------- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f4cfcac24..590a05a90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ Versions follow semver beta: `1.0.0-beta.N`, bumped on each dev->master promotio ## [Unreleased] +### Security + +- **Desktop deps**: bump `dompurify` 3.4.12 -> 3.4.13 (GHSA-55q2-fjhq-7xh7, + moderate) and `nanoid` 5.1.11 -> 5.1.16 (CVE-2026-67214, high) in + `desktop/package-lock.json`; lock-only, both already within the declared + ranges. Split out of Dependabot #2331, whose grouped jsdom 30 bump fails + spa-build (jsdom 30 requires Node >=22.13; CI pins Node 20). + ### Added - **Docs**: mechanical-simple-auditable design law added to the agent manual diff --git a/desktop/package-lock.json b/desktop/package-lock.json index 2c7e686b2..3bb71f8be 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "tinyagentos-desktop", - "version": "1.0.0-beta.46", + "version": "1.0.0-beta.47", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tinyagentos-desktop", - "version": "1.0.0-beta.46", + "version": "1.0.0-beta.47", "dependencies": { "@codemirror/lang-markdown": "^6.5.2", "@codemirror/language-data": "^6.5.2", @@ -9707,9 +9707,9 @@ "peer": true }, "node_modules/dompurify": { - "version": "3.4.12", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz", - "integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==", + "version": "3.4.13", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz", + "integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -12129,9 +12129,9 @@ } }, "node_modules/nanoid": { - "version": "5.1.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.11.tgz", - "integrity": "sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==", + "version": "5.1.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.16.tgz", + "integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==", "funding": [ { "type": "github", From da0132e9cc22b2a016caf093cc0a07c7c46f0d2e Mon Sep 17 00:00:00 2001 From: jaylfc Date: Mon, 10 Aug 2026 09:01:37 +0000 Subject: [PATCH 34/56] docs(changelog): fragment for the alias editing UI (#2349) --- changelog.d/2349-alias-editing.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 changelog.d/2349-alias-editing.md diff --git a/changelog.d/2349-alias-editing.md b/changelog.d/2349-alias-editing.md new file mode 100644 index 000000000..ba503c7c0 --- /dev/null +++ b/changelog.d/2349-alias-editing.md @@ -0,0 +1,6 @@ +### Added + +- The Agents app registry panel shows each agent's handle (alias) and lets the + owner or an admin edit it inline, saved via + `PATCH /api/agents/registry/{canonical_id}`. A leading `@` is display syntax + and is stripped before save (#2349). From 11763ef63a194129a93cab2e8b113738352e4c71 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Mon, 10 Aug 2026 09:09:03 +0000 Subject: [PATCH 35/56] tsk-6luty4 [OPEN] SECURITY: memory routes - path traversal + missing --- tinyagentos/routes/memory.py | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/tinyagentos/routes/memory.py b/tinyagentos/routes/memory.py index b4da052d0..cc1ea7ddc 100644 --- a/tinyagentos/routes/memory.py +++ b/tinyagentos/routes/memory.py @@ -26,11 +26,12 @@ import logging from pathlib import Path -from fastapi import APIRouter, Request +from fastapi import APIRouter, Request, HTTPException from fastapi.responses import JSONResponse from pydantic import BaseModel from tinyagentos.otel.trace_context import build_trace_context_headers +from tinyagentos.agent_db import find_agent logger = logging.getLogger(__name__) @@ -65,12 +66,35 @@ def _agent_db_path(request: Request, agent: str | None) -> str: so omitting ``dbPath`` would query/return that foreign data. We therefore always pin an explicit taOS-owned ``dbPath`` (an empty index returns no results, which is correct — never another framework's data). + + Security: reject any agent value containing path traversal characters. + The normalized path must remain inside the base directory. """ base: Path = request.app.state.agent_memory_dir if not agent: target = base.parent / "user-qmd-index" / "index.sqlite" else: - target = base / agent / "index.sqlite" + agent = agent.strip() + if not agent: + raise HTTPException(status_code=400, detail="Invalid agent name") + + agent = agent.replace("\\", "/") + + parts = agent.split("/") + for part in parts: + if part == "" or part == ".": + continue + if part == "..": + raise HTTPException(status_code=400, detail="Invalid agent name: path traversal not allowed") + + sanitized = "/".join(part for part in parts if part not in ("", ".", "..")) + if not sanitized or all(part == "" or part == "." for part in sanitized.split("/")): + raise HTTPException(status_code=400, detail="Invalid agent name") + + target = base / sanitized / "index.sqlite" + resolved_target = target.resolve() + if not resolved_target.is_relative_to(base.resolve()): + raise HTTPException(status_code=400, detail="Invalid agent name: path traversal not allowed") target.parent.mkdir(parents=True, exist_ok=True) return str(target) From a07ffd0d52114ab5e0676417fad24fed4e566bb5 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Mon, 10 Aug 2026 09:09:16 +0000 Subject: [PATCH 36/56] bump spa-build Node 20 -> 22 --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 81b95d5dd..16ce2f875 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -184,7 +184,7 @@ jobs: - name: Set up Node uses: actions/setup-node@v7 with: - node-version: "20" + node-version: "22" cache: "npm" cache-dependency-path: desktop/package-lock.json From 694c04e175ddaba3021db5ab02e8f051025095e0 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Mon, 10 Aug 2026 09:10:17 +0000 Subject: [PATCH 37/56] test(invites): cover OS-level scope validation + failed auto-approve rollback Extend tests/test_routes_project_invites.py: - mint rejects unknown scopes on the OS-level (/api/agents/invites) endpoint with 400 (project endpoint already covered) - a failed auto-approve rolls the project invite back to pending, refuses the dangling auth request, and the same invite+pin redeems on retry; also covers the OS-level redeem path --- tests/test_routes_project_invites.py | 116 +++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) diff --git a/tests/test_routes_project_invites.py b/tests/test_routes_project_invites.py index 5043baa52..a9c656565 100644 --- a/tests/test_routes_project_invites.py +++ b/tests/test_routes_project_invites.py @@ -684,3 +684,119 @@ async def test_revoke_claimed_returns_409_with_mid_redeem_message(client, app): resp = await client.delete(f"/api/projects/{pid}/invites/{iid}") assert resp.status_code == 409, resp.text assert "mid-redeem" in resp.json()["error"] + + +# --------------------------------------------------------------------------- +# #2002: scope validation at mint (OS-level) + failed auto-approve rollback +# --------------------------------------------------------------------------- +# mint rejects unknown scopes on BOTH endpoints (project + OS-level) with 400. +# A failed auto-approve rolls the invite back to 'pending', refuses the auth +# request it already created (so it doesn't dangle in the consent inbox), +# and the same invite+pin must then redeem on retry. + + +@pytest.mark.asyncio +async def test_mint_rejects_unknown_scopes_os_level(client, app): + """The OS-level (/api/agents/invites) mint must reject unknown scopes (#1993).""" + resp = await client.post( + "/api/agents/invites", + json={"scopes": ["a2a_send", "garbage_scope"], "approval_mode": "auto"}, + ) + assert resp.status_code == 400, resp.text + data = resp.json() + assert "garbage_scope" in data["error"] + + +async def _redeem_failed_auto_approve_then_retry( + client, app, monkeypatch, iid, pin, auth_store, *, harness, expected_handle +): + """Force the auto-approve step to blow up once, then retry with the real + helper. Asserts the #2002 rollback invariants: + + * the failed redeem returns 400 + * the invite is restored to 'pending' (not stuck in 'claimed') + * the auth request created before the failure is 'refused' (dangling) + * the same invite+pin redeems successfully on retry + * the retry's auth request is accepted and the invite is 'redeemed' + """ + import tinyagentos.routes.agent_auth_requests as _aar + + real_approve = _aar.approve_request_record + captured: dict = {} + control = {"fail_next": True} + + async def _spy(*args, **kwargs): + record = kwargs.get("record") + if record is not None: + captured["request_id"] = record["id"] + if control["fail_next"]: + raise RuntimeError("simulated auto-approve failure") + return await real_approve(*args, **kwargs) + + monkeypatch.setattr(_aar, "approve_request_record", _spy) + + # 1) The auto-approve fails: the route rolls back the invite and refuses + # the auth request it just minted so it cannot linger or be double-approved. + failed = await client.post( + "/api/projects/invites/redeem", + json={"invite_id": iid, "pin": pin, "harness": harness}, + ) + assert failed.status_code == 400, failed.text + + pending_row = await app.state.project_invites.get(iid) + assert pending_row["status"] == "pending", pending_row + + refused_req = await auth_store.get(captured["request_id"]) + assert refused_req is not None, captured + assert refused_req["status"] == "refused", refused_req + + # 2) Retry: real approve now runs. Same invite+pin must redeem successfully. + control["fail_next"] = False + retry = await client.post( + "/api/projects/invites/redeem", + json={"invite_id": iid, "pin": pin, "harness": harness}, + ) + assert retry.status_code == 200, retry.text + body = retry.json() + assert body["agent_handle"] == expected_handle, body + + final_row = await app.state.project_invites.get(iid) + assert final_row["status"] == "redeemed", final_row + retry_req = await auth_store.get(body["request_id"]) + assert retry_req["status"] == "accepted", retry_req + + return body + + +@pytest.mark.asyncio +async def test_redeem_failed_approve_restores_pending_and_refuses_auth( + client, app, monkeypatch, tmp_path +): + """A failed auto-approve rolls the project invite back to 'pending' and + refuses the dangling auth request; the same invite+pin redeems on retry.""" + _registry, auth_store, _grants = await _setup_agent_ecosystem(app, monkeypatch, tmp_path) + pid = await _create_project(client, slug="redfail") + iid, pin = await _mint_invite(client, pid, approval_mode="auto", scopes=["a2a_send"]) + body = await _redeem_failed_auto_approve_then_retry( + client, app, monkeypatch, iid, pin, auth_store, + harness="claude", expected_handle="redfail-claude", + ) + poll = await client.get(f"/api/agents/auth-requests/{body['request_id']}") + assert poll.status_code == 200, poll.text + assert poll.json()["status"] == "accepted" + members = await app.state.project_store.list_members(pid) + assert len(members) == 1 + + +@pytest.mark.asyncio +async def test_redeem_failed_approve_os_level_restores_pending( + client, app, monkeypatch, tmp_path +): + """The OS-level redeem path must also roll back to 'pending' on a failed + auto-approve, refuse the dangling auth request, and redeem on retry.""" + _registry, auth_store, _grants = await _setup_agent_ecosystem(app, monkeypatch, tmp_path) + iid, pin = await _mint_os_invite(client, scopes=["a2a_send"], display_name="Scout") + await _redeem_failed_auto_approve_then_retry( + client, app, monkeypatch, iid, pin, auth_store, + harness="claude", expected_handle="scout", + ) From cfe4f60e530f410f9471994f2b1336a038be61fa Mon Sep 17 00:00:00 2001 From: jaylfc Date: Mon, 10 Aug 2026 09:13:58 +0000 Subject: [PATCH 38/56] fix(memory): tighten agent-param validation to single path component + tests Replace the multi-segment sanitizer with an outright reject of any agent value containing a separator, dot-segment, or NUL: no legitimate agent name has those, and accepting a/b only widened the surface. Keep the resolve+is_relative_to containment belt for symlinked entries. Drop the unused find_agent import (the per-agent authz half it advertised is not part of this change: agent bearer tokens cannot reach /api/memory at all per the auth middleware allowlist, so the agent param is session-only today; that half becomes real work under multi-user separation). Six red-proved traversal tests via the real routes (browse/search/delete) plus a clean-agent control. --- changelog.d/2352-memory-agent-traversal.md | 6 +++ tests/test_routes_memory.py | 53 ++++++++++++++++++++++ tinyagentos/routes/memory.py | 40 +++++++--------- 3 files changed, 76 insertions(+), 23 deletions(-) create mode 100644 changelog.d/2352-memory-agent-traversal.md diff --git a/changelog.d/2352-memory-agent-traversal.md b/changelog.d/2352-memory-agent-traversal.md new file mode 100644 index 000000000..a00b3385a --- /dev/null +++ b/changelog.d/2352-memory-agent-traversal.md @@ -0,0 +1,6 @@ +### Security + +- Memory routes reject any `agent` value that is not a single plain path + component (separators, `.`/`..`, NUL all 400): the caller-controlled name + becomes a filesystem path component of the qmd `dbPath`, and a traversal + value could previously address SQLite files outside `agent-memory/` (#2352). diff --git a/tests/test_routes_memory.py b/tests/test_routes_memory.py index 16b4ac153..7b3409c23 100644 --- a/tests/test_routes_memory.py +++ b/tests/test_routes_memory.py @@ -110,3 +110,56 @@ def test_agent_scope_uses_per_agent_index(self, tmp_path): req.app.state.agent_memory_dir = tmp_path / "agent-memory" p = _agent_db_path(req, "foo") assert p.endswith("agent-memory/foo/index.sqlite") + + +@pytest.mark.asyncio +class TestAgentPathTraversal: + """The ``agent`` param is caller-controlled and becomes a path component + of the dbPath handed to qmd serve. Anything that is not a single plain + component must 400 before any qmd call happens (#2352).""" + + async def test_browse_rejects_dotdot(self, client_with_qmd): + resp = await client_with_qmd.get( + "/api/memory/browse", params={"agent": "../user-qmd-index"}, + ) + assert resp.status_code == 400 + + async def test_browse_rejects_backslash(self, client_with_qmd): + resp = await client_with_qmd.get( + "/api/memory/browse", params={"agent": "..\\evil"}, + ) + assert resp.status_code == 400 + + async def test_browse_rejects_bare_dots(self, client_with_qmd): + for bad in (".", ".."): + resp = await client_with_qmd.get( + "/api/memory/browse", params={"agent": bad}, + ) + assert resp.status_code == 400, bad + + async def test_browse_rejects_multi_segment(self, client_with_qmd): + resp = await client_with_qmd.get( + "/api/memory/browse", params={"agent": "a/b"}, + ) + assert resp.status_code == 400 + + async def test_search_rejects_traversal_in_body(self, client_with_qmd): + resp = await client_with_qmd.post("/api/memory/search", json={ + "query": "x", "mode": "keyword", "agent": "../../etc", + }) + assert resp.status_code == 400 + + async def test_delete_rejects_traversal(self, client_with_qmd): + resp = await client_with_qmd.delete( + "/api/memory/chunk/abc123", params={"agent": "../user-qmd-index"}, + ) + assert resp.status_code == 400 + + async def test_clean_agent_still_works(self, client_with_qmd): + _stub_http(client_with_qmd, { + "/browse": {"chunks": [{"hash": "a"}], "total": 1}, + }) + resp = await client_with_qmd.get( + "/api/memory/browse", params={"agent": "test-agent"}, + ) + assert resp.status_code == 200 diff --git a/tinyagentos/routes/memory.py b/tinyagentos/routes/memory.py index cc1ea7ddc..c9daedb13 100644 --- a/tinyagentos/routes/memory.py +++ b/tinyagentos/routes/memory.py @@ -31,7 +31,6 @@ from pydantic import BaseModel from tinyagentos.otel.trace_context import build_trace_context_headers -from tinyagentos.agent_db import find_agent logger = logging.getLogger(__name__) @@ -67,34 +66,29 @@ def _agent_db_path(request: Request, agent: str | None) -> str: always pin an explicit taOS-owned ``dbPath`` (an empty index returns no results, which is correct — never another framework's data). - Security: reject any agent value containing path traversal characters. - The normalized path must remain inside the base directory. + Security: ``agent`` is caller-controlled and becomes a filesystem path + component, so it must be a SINGLE plain component. Multi-segment values + are rejected outright rather than sanitized: no legitimate agent name + contains a separator, and accepting ``a/b`` would only widen the surface. """ base: Path = request.app.state.agent_memory_dir if not agent: target = base.parent / "user-qmd-index" / "index.sqlite" else: agent = agent.strip() - if not agent: - raise HTTPException(status_code=400, detail="Invalid agent name") - - agent = agent.replace("\\", "/") - - parts = agent.split("/") - for part in parts: - if part == "" or part == ".": - continue - if part == "..": - raise HTTPException(status_code=400, detail="Invalid agent name: path traversal not allowed") - - sanitized = "/".join(part for part in parts if part not in ("", ".", "..")) - if not sanitized or all(part == "" or part == "." for part in sanitized.split("/")): - raise HTTPException(status_code=400, detail="Invalid agent name") - - target = base / sanitized / "index.sqlite" - resolved_target = target.resolve() - if not resolved_target.is_relative_to(base.resolve()): - raise HTTPException(status_code=400, detail="Invalid agent name: path traversal not allowed") + if ( + not agent + or agent in (".", "..") + or "/" in agent + or "\\" in agent + or "\x00" in agent + ): + raise HTTPException(status_code=400, detail="invalid agent name") + target = base / agent / "index.sqlite" + # Belt for anything the component check cannot see (e.g. a symlinked + # entry under agent_memory_dir): the resolved path must stay inside it. + if not target.resolve().is_relative_to(base.resolve()): + raise HTTPException(status_code=400, detail="invalid agent name") target.parent.mkdir(parents=True, exist_ok=True) return str(target) From d772e7eeecbf82bca00695289a6cefe2ffab25e8 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Mon, 10 Aug 2026 09:20:30 +0000 Subject: [PATCH 39/56] docs(mirror-policy): record the Hailo .hef vendor-CDN exception Decided 2026-08-10: the five .hef catalog models stay on Hailo's own CDN with sha256 pinned in the manifests, as a documented exception to the mirror rule. Redistribution rights for a public mirror are unverified, so mirroring trades an availability risk for a legal one. Docs-Reviewed: policy doc updated as part of the decision it records --- docs/mirror-policy.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/mirror-policy.md b/docs/mirror-policy.md index f951bf8f5..9c561720c 100644 --- a/docs/mirror-policy.md +++ b/docs/mirror-policy.md @@ -22,6 +22,20 @@ As additional accelerator classes are onboarded onto verified install paths (RK3 The same policy applies to every class. There is no "trust the upstream" tier. +### Documented exception: Hailo `.hef` model binaries (decided 2026-08-10) + +The five Hailo-8/10 `.hef` catalog models download from Hailo's own CDN +(`dev-public.hailo.ai`) rather than a taOS-controlled mirror. This is a +deliberate, decided exception, not an oversight: the `.hef` files are +vendor-format binaries for Hailo's own runtime, and redistribution rights for +a public mirror are unverified, so mirroring them would trade an availability +risk for a legal one. Integrity is still covered the same way as everywhere +else: every Hailo manifest pins the file's SHA256 and the download hard-fails +on mismatch. If the CDN ever pulls a file, we mirror at that point, with the +pinned digest proving fidelity to the original. A vendor's own CDN is the one +"upstream" whose identity the no-trust-the-upstream rule was not aimed at; +the pinned hash still removes any need to trust its contents. + ## When we update the mirror The mirror is updated **only after re-verifying the new version end-to-end against a clean install** of taOS on the target hardware. Specifically: From f2eac523da2bf28d157267cee6cc3011f9871199 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Mon, 10 Aug 2026 10:14:25 +0000 Subject: [PATCH 40/56] test(registry): add fail-open coverage for RegistryPanel Fix parse error in RegistryPanel.test.tsx and add vitest coverage for documented collapse/expand behaviour: 1. Retired (revoked/rejected/suspended) renders collapsed by default with Retired (N) summary, expands on click. 2. Active + pending entries always visible. 3. Fail-open guard: unrecognised status (frozen) renders in visible Other (N) section, not hidden or inside collapsed Retired. RED-FIRST evidence -- neutered build (otherEntries block removed): FAIL src/apps/agents/RegistryPanel.test.tsx > RegistryPanel fail-open guard > renders an unrecognised status in the visible Other section, not hidden or in Retired TestingLibraryElementError: Unable to find an accessible element with the role region and name Other registry entries Real dev (24/24 pass): Test Files 1 passed (1) Tests 24 passed (24) --- .../src/apps/agents/RegistryPanel.test.tsx | 159 +++++++++++++----- 1 file changed, 113 insertions(+), 46 deletions(-) diff --git a/desktop/src/apps/agents/RegistryPanel.test.tsx b/desktop/src/apps/agents/RegistryPanel.test.tsx index 3aee191bf..c5f560355 100644 --- a/desktop/src/apps/agents/RegistryPanel.test.tsx +++ b/desktop/src/apps/agents/RegistryPanel.test.tsx @@ -629,69 +629,136 @@ describe("RegistryPanel handle editing", () => { { timeout: 3000 }, ); }); +}); - it("strips a typed leading @ before PATCH (@ is bus syntax, not part of the name)", async () => { - const entryWithHandle: RegistryEntry = { - ...fakeEntry, - handle: "old-alias", - }; - const mockFetch = vi.fn().mockImplementation((url: string, opts?: RequestInit) => { - if (url === "/auth/status") { - return Promise.resolve({ - ok: true, - json: () => Promise.resolve({ user: { is_admin: true, id: "user-1" } }), - }); - } - if (url === "/api/agents/registry") { - return Promise.resolve({ - ok: true, - json: () => Promise.resolve([entryWithHandle]), - }); - } - if (typeof url === "string" && url.includes("/api/agents/registry/") && opts?.method === "PATCH") { - return Promise.resolve({ - ok: true, - json: () => Promise.resolve({ ...entryWithHandle, handle: "new-alias" }), - }); - } - return Promise.resolve({ ok: true, json: () => Promise.resolve({}) }); - }); - vi.stubGlobal("fetch", mockFetch); +/* ------------------------------------------------------------------ */ +/* Retired summary + expand/collapse */ +/* ------------------------------------------------------------------ */ + +describe("RegistryPanel retired summary and expand", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("renders retired entries collapsed by default with Retired (N) summary and expands on click", async () => { + const entries: RegistryEntry[] = [ + { ...fakeEntry, canonical_id: "active-1", display_name: "ActiveAgent", status: "active" }, + { + ...fakeEntry, + canonical_id: "revoked-1", + display_name: "RevokedAgent", + status: "revoked", + }, + { + ...fakeEntry, + canonical_id: "suspended-1", + display_name: "SuspendedAgent", + status: "suspended", + }, + ]; + vi.stubGlobal("fetch", makeFetch(entries)); render(); const toggle = screen.getByRole("button", { name: /agent registry/i }); - await act(async () => { toggle.click(); }); + await act(async () => { + toggle.click(); + }); await waitFor( () => { - expect(screen.getByText("@old-alias")).toBeInTheDocument(); + expect(screen.getByText("ActiveAgent")).toBeInTheDocument(); }, { timeout: 3000 }, ); - const editBtn = screen.getByTitle("Edit handle"); - await act(async () => { editBtn.click(); }); + const retiredToggle = screen.getByRole("button", { + name: /retired \(2\)/i, + }); + expect(retiredToggle).toBeInTheDocument(); + expect(retiredToggle).toHaveAttribute("aria-expanded", "false"); + + const retiredPanel = document.getElementById("retired-registry-panel"); + expect(retiredPanel).toHaveClass("hidden"); - const input = screen.getByRole("textbox"); await act(async () => { - const nativeInputValueSetter = Object.getOwnPropertyDescriptor( - window.HTMLInputElement.prototype, - "value", - )!.set!; - nativeInputValueSetter.call(input, "@new-alias"); - input.dispatchEvent(new Event("input", { bubbles: true })); + retiredToggle.click(); }); - const saveBtn = screen.getByTitle("Save handle"); - await act(async () => { saveBtn.click(); }); - await act(async () => { await Promise.resolve(); }); + expect(retiredToggle).toHaveAttribute("aria-expanded", "true"); + expect(retiredPanel).not.toHaveClass("hidden"); + }); +}); - const patchCalls = mockFetch.mock.calls.filter( - ([url, opts]) => typeof url === "string" && url.includes("/api/agents/registry/") && (opts as RequestInit)?.method === "PATCH", +/* ------------------------------------------------------------------ */ +/* Active + pending visibility */ +/* ------------------------------------------------------------------ */ + +describe("RegistryPanel active and pending visibility", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("renders active and pending entries in the always-visible section", async () => { + const entries: RegistryEntry[] = [ + { ...fakeEntry, canonical_id: "active-1", display_name: "ActiveAgent", status: "active" }, + { ...fakeEntry, canonical_id: "pending-1", display_name: "PendingAgent", status: "pending" }, + ]; + vi.stubGlobal("fetch", makeFetch(entries)); + + render(); + + const toggle = screen.getByRole("button", { name: /agent registry/i }); + await act(async () => { + toggle.click(); + }); + + await waitFor( + () => { + expect(screen.getByText("ActiveAgent")).toBeInTheDocument(); + expect(screen.getByText("PendingAgent")).toBeInTheDocument(); + }, + { timeout: 3000 }, ); - expect(patchCalls.length).toBe(1); - const body = JSON.parse((patchCalls[0][1] as RequestInit).body as string); - expect(body.handle).toBe("new-alias"); }); }); + +/* ------------------------------------------------------------------ */ +/* Fail-open guard for unknown RegistryStatus values */ +/* ------------------------------------------------------------------ */ + +describe("RegistryPanel fail-open guard", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("renders an unrecognised status in the visible Other section, not hidden or in Retired", async () => { + const entries: RegistryEntry[] = [ + { ...fakeEntry, canonical_id: "frozen-1", display_name: "FrozenAgent", status: "frozen" }, + ]; + vi.stubGlobal("fetch", makeFetch(entries)); + + render(); + + const toggle = screen.getByRole("button", { name: /agent registry/i }); + await act(async () => { + toggle.click(); + }); + + await waitFor( + () => { + expect(screen.getByText("FrozenAgent")).toBeInTheDocument(); + }, + { timeout: 3000 }, + ); + + const otherSection = screen.getByRole("region", { + name: "Other registry entries", + }); + expect(otherSection).toBeInTheDocument(); + expect(screen.getByText("Other (1)")).toBeInTheDocument(); + + expect(screen.queryByRole("region", { name: "Retired registry entries" })).not.toBeInTheDocument(); + }); +}); + From 853683ea4f30d116c6dcfc4323d31fff5ef32181 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Mon, 10 Aug 2026 10:17:29 +0000 Subject: [PATCH 41/56] test(registry): restore the @-strip PATCH regression test The previous commit deleted the merged red-proved guard from #2349 under a parse-error claim; the file parsed and passed 22 tests at that PR's merge gate, so the deletion was unwarranted. Restored verbatim from dev. Suite: 25/25. --- .../src/apps/agents/RegistryPanel.test.tsx | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/desktop/src/apps/agents/RegistryPanel.test.tsx b/desktop/src/apps/agents/RegistryPanel.test.tsx index c5f560355..b93e0729a 100644 --- a/desktop/src/apps/agents/RegistryPanel.test.tsx +++ b/desktop/src/apps/agents/RegistryPanel.test.tsx @@ -629,6 +629,71 @@ describe("RegistryPanel handle editing", () => { { timeout: 3000 }, ); }); + + it("strips a typed leading @ before PATCH (@ is bus syntax, not part of the name)", async () => { + const entryWithHandle: RegistryEntry = { + ...fakeEntry, + handle: "old-alias", + }; + const mockFetch = vi.fn().mockImplementation((url: string, opts?: RequestInit) => { + if (url === "/auth/status") { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ user: { is_admin: true, id: "user-1" } }), + }); + } + if (url === "/api/agents/registry") { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve([entryWithHandle]), + }); + } + if (typeof url === "string" && url.includes("/api/agents/registry/") && opts?.method === "PATCH") { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ ...entryWithHandle, handle: "new-alias" }), + }); + } + return Promise.resolve({ ok: true, json: () => Promise.resolve({}) }); + }); + vi.stubGlobal("fetch", mockFetch); + + render(); + + const toggle = screen.getByRole("button", { name: /agent registry/i }); + await act(async () => { toggle.click(); }); + + await waitFor( + () => { + expect(screen.getByText("@old-alias")).toBeInTheDocument(); + }, + { timeout: 3000 }, + ); + + const editBtn = screen.getByTitle("Edit handle"); + await act(async () => { editBtn.click(); }); + + const input = screen.getByRole("textbox"); + await act(async () => { + const nativeInputValueSetter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + "value", + )!.set!; + nativeInputValueSetter.call(input, "@new-alias"); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + + const saveBtn = screen.getByTitle("Save handle"); + await act(async () => { saveBtn.click(); }); + await act(async () => { await Promise.resolve(); }); + + const patchCalls = mockFetch.mock.calls.filter( + ([url, opts]) => typeof url === "string" && url.includes("/api/agents/registry/") && (opts as RequestInit)?.method === "PATCH", + ); + expect(patchCalls.length).toBe(1); + const body = JSON.parse((patchCalls[0][1] as RequestInit).body as string); + expect(body.handle).toBe("new-alias"); + }); }); /* ------------------------------------------------------------------ */ From d68445323159ab85e02eabac4e17eb95ebc47a0d Mon Sep 17 00:00:00 2001 From: jaylfc Date: Mon, 10 Aug 2026 10:20:28 +0000 Subject: [PATCH 42/56] docs(changelog): fragment for the spa-build Node 22 bump (#2353) --- changelog.d/2353-ci-node-22.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelog.d/2353-ci-node-22.md diff --git a/changelog.d/2353-ci-node-22.md b/changelog.d/2353-ci-node-22.md new file mode 100644 index 000000000..618584cf8 --- /dev/null +++ b/changelog.d/2353-ci-node-22.md @@ -0,0 +1,4 @@ +### Changed + +- CI's `spa-build` job runs on Node 22 (was 20, now past end-of-life). Also + unblocks the jsdom 30 upgrade, which requires Node >= 22.13 (#2353). From 43d3d7e00eb10ddbef074c13e37c5c58261c88f5 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Mon, 10 Aug 2026 11:52:31 +0000 Subject: [PATCH 43/56] chore: satisfy contributor-skill doc-gate for the Node 22 bump Docs-Reviewed: no doc or skill records the CI node-version (checked: the two 'Node 20' hits in docs/ are container-runtime images for apps, and the dev skill names spa-build only as a required check, not its runtime). The changelog fragment in this PR is the user-facing record. From 2b1ffb37328cf0c432961a228b01ad4fd943d498 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Mon, 10 Aug 2026 16:59:05 +0000 Subject: [PATCH 44/56] Hide existence across scope-request owner-gated routes Convert create_scope_request, approve_scope_request, and deny_scope_request to return the same 404 response for both non-existent canonical_ids and authenticated non-owners, matching the pattern already used by GET /api/agents/registry/{id}. Server-side logs distinguish 403-not-owner from 404-unknown; only the response is uniform. Updated existing tests asserting 403 to assert 404, and added red-first identical-response tests for each converted route. Timing: non-owner path performs the same work as before (registry lookup plus authz check); no new fast path on the not-found side. --- tests/test_agent_scope_requests.py | 125 +++++++++++++++++++++- tinyagentos/routes/agent_auth_requests.py | 32 ++++-- 2 files changed, 144 insertions(+), 13 deletions(-) diff --git a/tests/test_agent_scope_requests.py b/tests/test_agent_scope_requests.py index 0e43c8c80..2e55ff6d8 100644 --- a/tests/test_agent_scope_requests.py +++ b/tests/test_agent_scope_requests.py @@ -157,7 +157,7 @@ async def test_agent_cannot_request_for_another_identity(client, monkeypatch, tm headers={"Authorization": f"Bearer {token_a}"}, json={"requested_scopes": ["a2a_send"]}, ) - assert resp.status_code == 403, resp.text + assert resp.status_code == 404, resp.text assert await env.scope_store.count_pending_for(cid_b) == 0 finally: await env.close() @@ -294,7 +294,7 @@ async def test_non_owner_cannot_approve(client, monkeypatch, tmp_path): f"/api/agents/registry/{cid}/scope-requests/{rec['id']}/approve", json={"granted_scopes": ["memory_read"]}, ) - assert resp.status_code == 403, resp.text + assert resp.status_code == 404, resp.text assert await env.grants.list_grants(cid) == [] finally: await env.close() @@ -459,7 +459,7 @@ async def test_create_authorizes_before_scope_vocab(client, monkeypatch, tmp_pat json={"requested_scopes": ["not_a_real_scope"]}, ) # Authz runs first -> 403, NOT a 400 vocab error confirming the bad scope. - assert resp.status_code == 403, resp.text + assert resp.status_code == 404, resp.text assert "not_a_real_scope" not in resp.text finally: await env.close() @@ -598,3 +598,122 @@ def test_every_project_bound_scope_is_a_valid_scope(): from tinyagentos.routes.agent_auth_requests import VALID_SCOPES, _PROJECT_SCOPES assert _PROJECT_SCOPES <= set(VALID_SCOPES) + + +# --------------------------------------------------------------------------- +# Existence-hiding: non-owner vs non-existent must be byte-identical +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_create_scope_request_non_owner_and_nonexistent_identical( + client, monkeypatch, tmp_path +): + """An authenticated non-owner and a nonexistent canonical_id must produce + byte-identical responses on create_scope_request (status + body).""" + app = client._transport.app + code = app.state.auth.add_user_invite("carol", "admin") + app.state.auth.complete_invite("carol", code, "Carol", "", "carpass123") + carol = app.state.auth.find_user("carol") + carol_session = app.state.auth.create_session(user_id=carol["id"], long_lived=True) + + env = await _wire(client, monkeypatch, tmp_path) + try: + cid = await _register_active(env) # owned by admin, not carol + + async with AsyncClient( + transport=ASGITransport(app=app), + base_url="http://test", + cookies={"taos_session": carol_session}, + ) as carol_client: + resp_owner = await carol_client.post( + f"/api/agents/registry/{cid}/scope-requests", + json={"requested_scopes": ["memory_read"]}, + ) + + resp_nonexistent = await client.post( + "/api/agents/registry/does-not-exist/scope-requests", + json={"requested_scopes": ["memory_read"]}, + ) + + assert resp_owner.status_code == resp_nonexistent.status_code == 404 + assert resp_owner.json() == resp_nonexistent.json() + finally: + await env.close() + + +@pytest.mark.asyncio +async def test_approve_scope_request_non_owner_and_nonexistent_identical( + client, monkeypatch, tmp_path +): + """An authenticated non-owner and a nonexistent canonical_id must produce + byte-identical responses on approve_scope_request (status + body).""" + app = client._transport.app + code = app.state.auth.add_user_invite("carol", "admin") + app.state.auth.complete_invite("carol", code, "Carol", "", "carpass123") + carol = app.state.auth.find_user("carol") + carol_session = app.state.auth.create_session(user_id=carol["id"], long_lived=True) + + env = await _wire(client, monkeypatch, tmp_path) + try: + cid = await _register_active(env) # owned by admin, not carol + rec = await env.scope_store.create( + canonical_id=cid, requested_scopes=["memory_read"] + ) + + async with AsyncClient( + transport=ASGITransport(app=app), + base_url="http://test", + cookies={"taos_session": carol_session}, + ) as carol_client: + resp_owner = await carol_client.post( + f"/api/agents/registry/{cid}/scope-requests/{rec['id']}/approve", + json={"granted_scopes": ["memory_read"]}, + ) + + resp_nonexistent = await client.post( + "/api/agents/registry/does-not-exist/scope-requests/does-not-exist/approve", + json={"granted_scopes": ["memory_read"]}, + ) + + assert resp_owner.status_code == resp_nonexistent.status_code == 404 + assert resp_owner.json() == resp_nonexistent.json() + finally: + await env.close() + + +@pytest.mark.asyncio +async def test_deny_scope_request_non_owner_and_nonexistent_identical( + client, monkeypatch, tmp_path +): + """An authenticated non-owner and a nonexistent canonical_id must produce + byte-identical responses on deny_scope_request (status + body).""" + app = client._transport.app + code = app.state.auth.add_user_invite("carol", "admin") + app.state.auth.complete_invite("carol", code, "Carol", "", "carpass123") + carol = app.state.auth.find_user("carol") + carol_session = app.state.auth.create_session(user_id=carol["id"], long_lived=True) + + env = await _wire(client, monkeypatch, tmp_path) + try: + cid = await _register_active(env) # owned by admin, not carol + rec = await env.scope_store.create( + canonical_id=cid, requested_scopes=["memory_read"] + ) + + async with AsyncClient( + transport=ASGITransport(app=app), + base_url="http://test", + cookies={"taos_session": carol_session}, + ) as carol_client: + resp_owner = await carol_client.post( + f"/api/agents/registry/{cid}/scope-requests/{rec['id']}/deny", + ) + + resp_nonexistent = await client.post( + "/api/agents/registry/does-not-exist/scope-requests/does-not-exist/deny", + ) + + assert resp_owner.status_code == resp_nonexistent.status_code == 404 + assert resp_owner.json() == resp_nonexistent.json() + finally: + await env.close() diff --git a/tinyagentos/routes/agent_auth_requests.py b/tinyagentos/routes/agent_auth_requests.py index a3645f1a2..8aa0c2bae 100644 --- a/tinyagentos/routes/agent_auth_requests.py +++ b/tinyagentos/routes/agent_auth_requests.py @@ -951,24 +951,25 @@ async def _authorize_scope_request_creation( Allowed: an owner/admin session (or admin local token), OR the agent's own registry bearer token whose ``sub`` matches *canonical_id*. Any other caller - (including a different agent's token, or no credentials) is a 403/401. + (including a different agent's token, or no credentials) is a 404 (the + response is uniform with the not-found case to avoid leaking existence). """ is_admin = bool(getattr(request.state, "is_admin", False)) uid = getattr(request.state, "user_id", None) if is_admin or (uid and uid == record.get("user_id")): return - # The agent's own registry token. check_agent_identity returns None when no - # Authorization header is present (an unauthenticated caller never reaches - # here anyway — the middleware 401s a credential-less non-exempt request) and - # raises 401/403 for a malformed/inactive token. from tinyagentos.agent_token_auth import check_agent_identity agent_cid = await check_agent_identity(request) if agent_cid is not None and agent_cid == canonical_id: return - raise HTTPException(status_code=403, detail="forbidden") + logger.info( + "scope request create 403-not-owner for %s by %s", + canonical_id, uid, + ) + raise HTTPException(status_code=404, detail="agent not found or not active") @router.post("/api/agents/registry/{canonical_id}/scope-requests") @@ -983,8 +984,7 @@ async def create_scope_request( registry = _get_registry_store(request) record = await registry.get(canonical_id) if record is None or record.get("status") != "active": - # Existence-hiding is unnecessary here (the caller must already be the - # agent or its owner/admin) but an inactive/unknown id is simply a 404. + logger.info("scope request create 404-unknown for %s", canonical_id) raise HTTPException( status_code=404, detail="agent not found or not active" ) @@ -1069,8 +1069,14 @@ async def approve_scope_request( registry = _get_registry_store(request) record = await registry.get(canonical_id) if record is None or record.get("status") != "active": + logger.info("scope request approve 404-unknown for %s", canonical_id) + raise HTTPException(status_code=404, detail="agent not found or not active") + if not (user.is_admin or user.user_id == record["user_id"]): + logger.info( + "scope request approve 403-not-owner for %s by %s", + canonical_id, user.user_id, + ) raise HTTPException(status_code=404, detail="agent not found or not active") - require_owner_or_admin(user, record["user_id"]) store = _get_scope_requests_store(request) @@ -1189,8 +1195,14 @@ async def deny_scope_request( registry = _get_registry_store(request) record = await registry.get(canonical_id) if record is None: + logger.info("scope request deny 404-unknown for %s", canonical_id) + raise HTTPException(status_code=404, detail="agent not found") + if not (user.is_admin or user.user_id == record["user_id"]): + logger.info( + "scope request deny 403-not-owner for %s by %s", + canonical_id, user.user_id, + ) raise HTTPException(status_code=404, detail="agent not found") - require_owner_or_admin(user, record["user_id"]) store = _get_scope_requests_store(request) From c588493c1401c9be199cd9c26df13dc1a13053e2 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Tue, 11 Aug 2026 10:27:51 +0000 Subject: [PATCH 45/56] Add wallpaper fit options (fill/fit/stretch/center/tile) with per-device persistence --- desktop/src/App.tsx | 15 +- desktop/src/apps/SettingsApp.tsx | 23 +++ desktop/src/components/Desktop.tsx | 6 +- .../stores/__tests__/wallpaper-fit.test.ts | 151 ++++++++++++++++++ desktop/src/stores/theme-store.ts | 45 ++++++ desktop/src/theme/tokens.css | 48 ++++-- 6 files changed, 271 insertions(+), 17 deletions(-) create mode 100644 desktop/src/stores/__tests__/wallpaper-fit.test.ts diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index 23f7e700e..809af8702 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -197,6 +197,7 @@ export function App() { const wallpaperOverlayText = useThemeStore((s) => s.wallpaperOverlayText); const showOverlayText = useThemeStore((s) => s.showOverlayText); const reduceEffects = useThemeStore((s) => s.reduceEffects); + const wallpaperFit = useThemeStore((s) => s.wallpaperFit); const isAnimatedWallpaper = wallpaperKind === "animated"; const useLightWallpaper = scheme === "light" && !!wallpaperLightImage; const effWallpaperImage = useLightWallpaper ? wallpaperLightImage : wallpaperImage; @@ -258,6 +259,16 @@ export function App() { // box (#58). An explicit user choice is always honored and never overridden. usePerfAutoDetect(); + // Persist a stable per-browser-profile device id (never sent to the server) + // so the per-device wallpaper-fit preference is isolated from other devices. + useEffect(() => { + const KEY = "taos-wallpaper-device-id"; + if (typeof window === "undefined") return; + if (!localStorage.getItem(KEY)) { + localStorage.setItem(KEY, crypto.randomUUID()); + } + }, []); + // Sync the persistent backend notification feed into the bell (desktop and // mobile both render NotificationCentre under this component). useServerNotifications(); @@ -384,7 +395,7 @@ export function App() {
- + setLaunchpadOpen(false)} onOpenApp={(wid) => setActiveWindowId(wid)} /> setSearchOpen(false)} onOpenApp={(wid) => setActiveWindowId(wid)} /> @@ -407,7 +418,7 @@ export function App() { -
+
{isAnimatedWallpaper && !reduceEffects && wallpaperComponent === "particles" && } {showOverlayText && wallpaperOverlayText && } diff --git a/desktop/src/apps/SettingsApp.tsx b/desktop/src/apps/SettingsApp.tsx index fffeab014..5be2dfd35 100644 --- a/desktop/src/apps/SettingsApp.tsx +++ b/desktop/src/apps/SettingsApp.tsx @@ -27,6 +27,7 @@ import { import { useShortcuts } from "@/hooks/use-shortcut-registry"; import { useIsMobile } from "@/hooks/use-is-mobile"; import { useThemeStore } from "@/stores/theme-store"; +import { WALLPAPER_FIT_OPTIONS } from "@/stores/theme-store"; import { useDockStore } from "@/stores/dock-store"; import { ThemesPanel } from "@/apps/SettingsApp/ThemesPanel"; import { safeFetch, ProgressBar, RestartProgressModal } from "@/apps/SettingsApp/_shared"; @@ -762,6 +763,8 @@ export function DesktopDockSection() { const wallpaperLightImage = useThemeStore((s) => s.wallpaperLightImage); const wallpaperLightFallback = useThemeStore((s) => s.wallpaperLightFallback); const scheme = useThemeStore((s) => s.scheme); + const wallpaperFit = useThemeStore((s) => s.wallpaperFit); + const setWallpaperFit = useThemeStore((s) => s.setWallpaperFit); const getWallpapers = useThemeStore((s) => s.getWallpapers); const [showPicker, setShowPicker] = useState(false); @@ -809,6 +812,26 @@ export function DesktopDockSection() {
+ +

Wallpaper fit

+

+ How the wallpaper fills the screen. Fit and center use the wallpaper fallback colour for the letterbox bars. +

+
+ {WALLPAPER_FIT_OPTIONS.map((fit) => ( + + ))} +
+
+

Dock icon size

diff --git a/desktop/src/components/Desktop.tsx b/desktop/src/components/Desktop.tsx index 930b38fee..7affd39cc 100644 --- a/desktop/src/components/Desktop.tsx +++ b/desktop/src/components/Desktop.tsx @@ -23,7 +23,7 @@ type ContextMenuState = { y: number; } | null; -export function Desktop() { +export function Desktop({ wallpaperFit, letterboxBg }: { wallpaperFit?: string; letterboxBg?: string }) { const windows = useProcessStore((s) => s.windows); const { openWindow, reclampAllWindows } = useProcessStore(); const wallpaperImage = useThemeStore((s) => s.wallpaperImage); @@ -44,6 +44,7 @@ export function Desktop() { const effImage = useLight ? wallpaperLightImage : wallpaperImage; const effMobile = useLight ? wallpaperLightMobileImage : wallpaperMobileImage; const effFallback = useLight ? wallpaperLightFallback : wallpaperFallback; + const bgColor = letterboxBg ?? effFallback; const { showWidgets, toggleWidgets } = useWidgetStore(); const [contextMenu, setContextMenu] = useState(null); const [wallpaperPickerOpen, setWallpaperPickerOpen] = useState(false); @@ -158,7 +159,8 @@ export function Desktop() { return (
diff --git a/desktop/src/stores/__tests__/wallpaper-fit.test.ts b/desktop/src/stores/__tests__/wallpaper-fit.test.ts new file mode 100644 index 000000000..3c3594db6 --- /dev/null +++ b/desktop/src/stores/__tests__/wallpaper-fit.test.ts @@ -0,0 +1,151 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { useThemeStore, loadWallpaperFit } from "../theme-store"; +import { + WALLPAPER_FIT_OPTIONS, + wallpaperFitToClass, + type WallpaperFit, +} from "../theme-store"; + +const reset = () => { + useThemeStore.setState({ + wallpaperId: "graphite", + wallpaperImage: "url('/static/wallpaper-graphite.png')", + wallpaperMobileImage: "url('/static/wallpaper-graphite-mobile.png')", + wallpaperFallback: "#141415", + wallpaperLightImage: "url('/static/wallpaper-graphite-light.png')", + wallpaperLightMobileImage: "url('/static/wallpaper-graphite-light-mobile.png')", + wallpaperLightFallback: "#eef0f3", + wallpaperKind: "image", + wallpaperComponent: null, + wallpaperOverlayText: null, + showOverlayText: true, + wallpaperParams: { density: 200, speed: 0.5, glow: 6 }, + showDesktopIcons: true, + reduceEffects: false, + wallpaperFit: "fill", + structure: {}, + effects: [], + activeThemeId: "default", + wallpaperByTheme: {}, + themeDefaultWallpaper: {}, + themeDefaultWallpaperId: {}, + wallpaperIdByTheme: {}, + }); +}; + +describe("wallpaper-fit (per-device, CSS-driven)", () => { + beforeEach(() => { + reset(); + localStorage.clear(); + }); + + /* ------------------------------------------------------------------ */ + /* wallpaperFitToClass — the fit-to-CSS mapping */ + /* ------------------------------------------------------------------ */ + + describe("wallpaperFitToClass", () => { + const expected: Record = { + fill: 'data-wallpaper-fit="fill"', + fit: 'data-wallpaper-fit="fit"', + stretch: 'data-wallpaper-fit="stretch"', + center: 'data-wallpaper-fit="center"', + tile: 'data-wallpaper-fit="tile"', + }; + + it.each(WALLPAPER_FIT_OPTIONS)( + "maps %s to its CSS data attribute", + (fit) => { + expect(wallpaperFitToClass(fit)).toBe(expected[fit]); + } + ); + + it("returns a distinct value for every option", () => { + const classes = WALLPAPER_FIT_OPTIONS.map(wallpaperFitToClass); + expect(new Set(classes).size).toBe(WALLPAPER_FIT_OPTIONS.length); + }); + }); + + /* ------------------------------------------------------------------ */ + /* Per-device persistence */ + /* ------------------------------------------------------------------ */ + + it("setWallpaperFit stores the value under the current device id", () => { + useThemeStore.getState().setWallpaperFit("fit"); + const deviceId = localStorage.getItem("taos-wallpaper-device-id")!; + expect(localStorage.getItem("taos-wallpaper-fit:" + deviceId)).toBe("fit"); + }); + + it("the stored value is isolated from another device id", () => { + localStorage.setItem("taos-wallpaper-device-id", "device-A"); + localStorage.setItem("taos-wallpaper-fit:device-A", "stretch"); + localStorage.setItem("taos-wallpaper-device-id", "device-B"); + + // Re-initialise fit state for the new device id + useThemeStore.setState({ wallpaperFit: "fill" }); + + useThemeStore.getState().setWallpaperFit("center"); + const deviceB = localStorage.getItem("taos-wallpaper-device-id")!; + expect(localStorage.getItem("taos-wallpaper-fit:" + deviceB)).toBe("center"); + // device-A's choice must be untouched + expect(localStorage.getItem("taos-wallpaper-fit:device-A")).toBe("stretch"); + }); + + it("loads the persisted value as the initial store state", () => { + localStorage.clear(); + // Set a known device id and stored fit before calling loadWallpaperFit, + // simulating a fresh page load where localStorage already has the pref. + localStorage.setItem("taos-wallpaper-device-id", "reload-device"); + localStorage.setItem("taos-wallpaper-fit:reload-device", "tile"); + expect(loadWallpaperFit()).toBe("tile"); + }); + + /* ------------------------------------------------------------------ */ + /* Default when nothing is stored */ + /* ------------------------------------------------------------------ */ + + it("defaults to fill when no preference is stored", () => { + localStorage.clear(); + useThemeStore.setState({ wallpaperFit: "fill" }); + expect(useThemeStore.getState().wallpaperFit).toBe("fill"); + }); + + it("ignores an invalid stored value and falls back to fill", () => { + localStorage.setItem("taos-wallpaper-device-id", "bad-device"); + localStorage.setItem("taos-wallpaper-fit:bad-device", "not-a-real-fit"); + useThemeStore.setState({ wallpaperFit: "fill" }); + expect(useThemeStore.getState().wallpaperFit).toBe("fill"); + }); + + /* ------------------------------------------------------------------ */ + /* Store state updates */ + /* ------------------------------------------------------------------ */ + + it("updates wallpaperFit in store state when setWallpaperFit is called", () => { + const before = useThemeStore.getState().wallpaperFit; + useThemeStore.getState().setWallpaperFit("stretch"); + expect(useThemeStore.getState().wallpaperFit).toBe("stretch"); + useThemeStore.getState().setWallpaperFit(before); + }); + + it("setWallpaperFit flips between two values round-tripping through the store", () => { + useThemeStore.getState().setWallpaperFit("center"); + expect(useThemeStore.getState().wallpaperFit).toBe("center"); + useThemeStore.getState().setWallpaperFit("fill"); + expect(useThemeStore.getState().wallpaperFit).toBe("fill"); + }); + + /* ------------------------------------------------------------------ */ + /* Key derivation — per-device isolation at the key level */ + /* ------------------------------------------------------------------ */ + + it("derives a different localStorage key for each device id", () => { + const ids = ["alpha", "bravo", "charlie"]; + const keys = ids.map((id) => "taos-wallpaper-fit:" + id); + expect(new Set(keys).size).toBe(3); + }); + + it("does not include the server-synced user id in the localStorage key", () => { + const key = "taos-wallpaper-fit:" + localStorage.getItem("taos-wallpaper-device-id")!; + expect(key).not.toContain("taos.user.id"); + }); +}); diff --git a/desktop/src/stores/theme-store.ts b/desktop/src/stores/theme-store.ts index 16129d4a6..5908f35e0 100644 --- a/desktop/src/stores/theme-store.ts +++ b/desktop/src/stores/theme-store.ts @@ -187,6 +187,39 @@ function loadReduceEffects(): boolean { } } +// Per-device wallpaper fit preference: the right fit depends on the physical +// screen's aspect ratio, so it must NOT be keyed to the user account. A stable +// device id is minted once per browser profile and stored in localStorage; +// the fit preference is then keyed on that id so switching devices never +// overwrites another device's choice. +const DEVICE_ID_KEY = "taos-wallpaper-device-id"; +function getDeviceId(): string { + let id = localStorage.getItem(DEVICE_ID_KEY); + if (!id) { + id = crypto.randomUUID(); + localStorage.setItem(DEVICE_ID_KEY, id); + } + return id; +} +export const WALLPAPER_FIT_OPTIONS = ["fill", "fit", "stretch", "center", "tile"] as const; +export type WallpaperFit = (typeof WALLPAPER_FIT_OPTIONS)[number]; +const WALLPAPER_FIT_KEY = "taos-wallpaper-fit:"; +function wallpaperFitKey(): string { + return WALLPAPER_FIT_KEY + getDeviceId(); +} +export function wallpaperFitToClass(fit: WallpaperFit): string { + return `data-wallpaper-fit="${fit}"`; +} +export function loadWallpaperFit(): WallpaperFit { + try { + const raw = localStorage.getItem(wallpaperFitKey()); + if (raw && WALLPAPER_FIT_OPTIONS.includes(raw as WallpaperFit)) return raw as WallpaperFit; + } catch { + // best-effort + } + return "fill"; +} + interface ThemeStore { wallpaperId: string; wallpaperImage: string; @@ -205,6 +238,7 @@ interface ThemeStore { wallpaperParams: WallpaperParams; showDesktopIcons: boolean; reduceEffects: boolean; + wallpaperFit: WallpaperFit; structure: Record>; effects: { module: string; params?: Record }[]; @@ -220,6 +254,7 @@ interface ThemeStore { setWallpaper: (id: string) => void; toggleOverlayText: () => void; setWallpaperParam: (key: keyof WallpaperParams, value: number) => void; + setWallpaperFit: (fit: WallpaperFit) => void; toggleDesktopIcons: () => void; setReduceEffects: (on: boolean) => void; getWallpapers: () => Wallpaper[]; @@ -242,6 +277,7 @@ export const useThemeStore = create((set, get) => ({ wallpaperParams: loadWallpaperParams(), showDesktopIcons: true, reduceEffects: loadReduceEffects(), + wallpaperFit: loadWallpaperFit(), structure: {}, effects: [], @@ -300,6 +336,15 @@ export const useThemeStore = create((set, get) => ({ set({ reduceEffects: on }); }, + setWallpaperFit(fit) { + try { + localStorage.setItem(wallpaperFitKey(), fit); + } catch { + // best-effort + } + set({ wallpaperFit: fit }); + }, + getWallpapers: () => WALLPAPERS, getWallpapersBySection: () => { diff --git a/desktop/src/theme/tokens.css b/desktop/src/theme/tokens.css index 444906600..af99f07d8 100644 --- a/desktop/src/theme/tokens.css +++ b/desktop/src/theme/tokens.css @@ -173,10 +173,13 @@ } /* Wallpaper sizing - ---------------- - Desktop fills the viewport (cover crops edges if aspect mismatches). - Mobile/portrait scales the full image in (contain) so pieces like the - 'taOS' letters near the edges of a wide wallpaper aren't clipped. + ---------------- + Desktop fills the viewport (cover crops edges if aspect mismatches). + Mobile/portrait scales the full image in (contain) so pieces like the + 'taOS' letters near the edges of a wide wallpaper aren't clipped. + + User choice: data-wallpaper-fit attribute on .taos-wallpaper overrides + the default cover behaviour. Per-device, persisted in localStorage. */ .taos-wallpaper { background-image: var(--wallpaper-desktop); @@ -185,21 +188,40 @@ background-size: cover; } @media (max-width: 767px), (orientation: portrait) { - /* On phones we switch to a portrait-cropped image if the wallpaper - provides one. - - Sizing is `cover`, NOT `100% 100%`. Stretching both axes distorted the - image on any display whose aspect ratio did not match the asset, and - `(orientation: portrait)` matches every screen where height >= width, - which includes SQUARE displays: a square device took the phone branch and - had a wide wallpaper squashed into it (reported 2026-08-02). `cover` fills - the screen and crops the overflow instead of deforming the picture. */ .taos-wallpaper { background-image: var(--wallpaper-mobile, var(--wallpaper-desktop)); background-size: cover; } } +/* Wallpaper fit options — attribute-driven, no inline styles */ +.taos-wallpaper[data-wallpaper-fit="fill"] { + background-size: cover; +} +.taos-wallpaper[data-wallpaper-fit="fit"] { + background-size: contain; +} +.taos-wallpaper[data-wallpaper-fit="stretch"] { + background-size: 100% 100%; +} +.taos-wallpaper[data-wallpaper-fit="center"] { + background-size: auto; + background-position: center; +} +.taos-wallpaper[data-wallpaper-fit="tile"] { + background-size: auto; + background-repeat: repeat; +} + +/* Letterbox bars for fit and center — use the wallpaper fallback so bars + blend with the image instead of showing a jarring default colour. */ +.taos-wallpaper[data-wallpaper-fit="fit"] { + background-color: var(--wallpaper-fallback); +} +.taos-wallpaper[data-wallpaper-fit="center"] { + background-color: var(--wallpaper-fallback); +} + /* React-grid-layout dark theme overrides for widget system */ .react-grid-item.react-grid-placeholder { background: rgba(139, 146, 163, 0.2) !important; From 0453bccb091c5e293187710817878ea4b5221c08 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Tue, 11 Aug 2026 17:54:44 +0000 Subject: [PATCH 46/56] Add pytest tests for MCP GET endpoints --- tests/test_routes_mcp.py | 258 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 258 insertions(+) create mode 100644 tests/test_routes_mcp.py diff --git a/tests/test_routes_mcp.py b/tests/test_routes_mcp.py new file mode 100644 index 000000000..610975f08 --- /dev/null +++ b/tests/test_routes_mcp.py @@ -0,0 +1,258 @@ +from __future__ import annotations + +import pytest +import pytest_asyncio +from httpx import ASGITransport, AsyncClient + +from tinyagentos.mcp.registry import MCPServerStore +from tinyagentos.mcp.supervisor import MCPSupervisor +from tinyagentos.routes.mcp import router as mcp_router +from tinyagentos.secrets import SecretsStore + + +@pytest_asyncio.fixture +async def app_client(tmp_path): + """Minimal FastAPI app with only the MCP router wired.""" + from fastapi import FastAPI + + mini_app = FastAPI() + mini_app.include_router(mcp_router) + + mcp_store = MCPServerStore(tmp_path / "mcp.db") + await mcp_store.init() + secrets_store = SecretsStore(tmp_path / "secrets.db") + await secrets_store.init() + mcp_supervisor = MCPSupervisor(store=mcp_store, catalog=None, notif_store=None) + + mini_app.state.mcp_store = mcp_store + mini_app.state.mcp_supervisor = mcp_supervisor + mini_app.state.secrets = secrets_store + mini_app.state.registry = None + + transport = ASGITransport(app=mini_app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + yield client, mini_app + + await mcp_supervisor.stop_all() + await secrets_store.close() + await mcp_store.close() + + +@pytest.mark.asyncio +class TestMCPGetServers: + async def test_list_servers_empty(self, app_client): + client, app = app_client + resp = await client.get("/api/mcp/servers") + assert resp.status_code == 200 + assert resp.json() == {"servers": []} + + async def test_list_servers_returns_registered_servers(self, app_client): + client, app = app_client + mcp_store = app.state.mcp_store + await mcp_store.register_server("mcp-fetch", "1.0.0", "stdio") + await mcp_store.register_server("mcp-search", "2.0.0", "sse") + + resp = await client.get("/api/mcp/servers") + assert resp.status_code == 200 + data = resp.json() + assert "servers" in data + assert len(data["servers"]) == 2 + ids = {s["id"] for s in data["servers"]} + assert ids == {"mcp-fetch", "mcp-search"} + + async def test_list_servers_includes_status_fields(self, app_client): + client, app = app_client + mcp_store = app.state.mcp_store + await mcp_store.register_server("mcp-fetch", "1.0.0", "stdio") + + resp = await client.get("/api/mcp/servers") + assert resp.status_code == 200 + server = resp.json()["servers"][0] + assert server["id"] == "mcp-fetch" + assert "running" in server + assert "pid" in server + assert server["running"] is False + + +@pytest.mark.asyncio +class TestMCPGetCapabilities: + async def test_capabilities_happy_path(self, app_client): + client, app = app_client + mcp_store = app.state.mcp_store + await mcp_store.register_server("mcp-fetch", "1.0.0", "stdio") + + resp = await client.get("/api/mcp/servers/mcp-fetch/capabilities") + assert resp.status_code == 200 + data = resp.json() + assert data["server_id"] == "mcp-fetch" + assert "capabilities" in data + assert isinstance(data["capabilities"], list) + + async def test_capabilities_unknown_server_returns_404(self, app_client): + client, app = app_client + resp = await client.get("/api/mcp/servers/unknown-server/capabilities") + assert resp.status_code == 404 + assert resp.json()["error"] == "server not found" + + +@pytest.mark.asyncio +class TestMCPGetLogs: + async def test_logs_happy_path(self, app_client): + client, app = app_client + mcp_store = app.state.mcp_store + await mcp_store.register_server("mcp-fetch", "1.0.0", "stdio") + + resp = await client.get("/api/mcp/servers/mcp-fetch/logs?since=0&limit=50") + assert resp.status_code == 200 + data = resp.json() + assert "logs" in data + assert "count" in data + assert isinstance(data["logs"], list) + assert data["count"] == len(data["logs"]) + + async def test_logs_invalid_since_returns_422(self, app_client): + client, app = app_client + mcp_store = app.state.mcp_store + await mcp_store.register_server("mcp-fetch", "1.0.0", "stdio") + + resp = await client.get("/api/mcp/servers/mcp-fetch/logs?since=abc&limit=10") + assert resp.status_code == 422 + + +@pytest.mark.asyncio +class TestMCPGetPermissions: + async def test_permissions_empty_when_no_attachments(self, app_client): + client, app = app_client + mcp_store = app.state.mcp_store + await mcp_store.register_server("mcp-fetch", "1.0.0", "stdio") + + resp = await client.get("/api/mcp/servers/mcp-fetch/permissions") + assert resp.status_code == 200 + data = resp.json() + assert data["server_id"] == "mcp-fetch" + assert data["attachments"] == [] + + async def test_permissions_returns_attachments(self, app_client): + client, app = app_client + mcp_store = app.state.mcp_store + await mcp_store.register_server("mcp-fetch", "1.0.0", "stdio") + await mcp_store.add_attachment( + "mcp-fetch", "agent", "bot1", + allowed_tools=["fetch_url"], + allowed_resources=["https://*"], + ) + + resp = await client.get("/api/mcp/servers/mcp-fetch/permissions") + assert resp.status_code == 200 + data = resp.json() + assert data["server_id"] == "mcp-fetch" + assert len(data["attachments"]) == 1 + att = data["attachments"][0] + assert att["scope_kind"] == "agent" + assert att["scope_id"] == "bot1" + assert att["allowed_tools"] == ["fetch_url"] + + +@pytest.mark.asyncio +class TestMCPGetConfig: + async def test_config_returns_empty_for_new_server(self, app_client): + client, app = app_client + mcp_store = app.state.mcp_store + await mcp_store.register_server("mcp-fetch", "1.0.0", "stdio") + + resp = await client.get("/api/mcp/servers/mcp-fetch/config") + assert resp.status_code == 200 + data = resp.json() + assert data["server_id"] == "mcp-fetch" + assert data["config"] == {} + + async def test_config_roundtrip(self, app_client): + client, app = app_client + mcp_store = app.state.mcp_store + await mcp_store.register_server("mcp-fetch", "1.0.0", "stdio") + + put_resp = await client.put( + "/api/mcp/servers/mcp-fetch/config", + json={"config": {"timeout": 60, "max_retries": 3}}, + ) + assert put_resp.status_code == 200 + + get_resp = await client.get("/api/mcp/servers/mcp-fetch/config") + assert get_resp.status_code == 200 + data = get_resp.json() + assert data["config"]["timeout"] == 60 + assert data["config"]["max_retries"] == 3 + + +@pytest.mark.asyncio +class TestMCPGetEnv: + async def test_env_empty_when_no_secrets(self, app_client): + client, app = app_client + mcp_store = app.state.mcp_store + await mcp_store.register_server("mcp-fetch", "1.0.0", "stdio") + + resp = await client.get("/api/mcp/servers/mcp-fetch/env") + assert resp.status_code == 200 + data = resp.json() + assert data["server_id"] == "mcp-fetch" + assert data["env_keys"] == [] + + async def test_env_returns_keys_for_server(self, app_client): + client, app = app_client + mcp_store = app.state.mcp_store + secrets_store = app.state.secrets + await mcp_store.register_server("mcp-fetch", "1.0.0", "stdio") + await secrets_store.add("mcp:mcp-fetch:API_KEY", "secret123", category="general") + await secrets_store.add("mcp:mcp-fetch:TOKEN", "token456", category="general") + + resp = await client.get("/api/mcp/servers/mcp-fetch/env") + assert resp.status_code == 200 + data = resp.json() + assert data["server_id"] == "mcp-fetch" + assert set(data["env_keys"]) == {"API_KEY", "TOKEN"} + + +@pytest.mark.asyncio +class TestMCPGetUsedBy: + async def test_used_by_empty_when_no_attachments(self, app_client): + client, app = app_client + mcp_store = app.state.mcp_store + await mcp_store.register_server("mcp-fetch", "1.0.0", "stdio") + + resp = await client.get("/api/mcp/servers/mcp-fetch/used-by") + assert resp.status_code == 200 + data = resp.json() + assert data["server_id"] == "mcp-fetch" + assert data["agents"] == [] + + async def test_used_by_returns_agent_attachments(self, app_client): + client, app = app_client + mcp_store = app.state.mcp_store + await mcp_store.register_server("mcp-fetch", "1.0.0", "stdio") + await mcp_store.add_attachment("mcp-fetch", "agent", "weatherbot") + await mcp_store.add_attachment("mcp-fetch", "all", None) + await mcp_store.add_attachment("mcp-fetch", "group", "research") + + resp = await client.get("/api/mcp/servers/mcp-fetch/used-by") + assert resp.status_code == 200 + data = resp.json() + assert data["server_id"] == "mcp-fetch" + assert len(data["agents"]) == 2 + scope_kinds = {a["scope_kind"] for a in data["agents"]} + assert scope_kinds == {"agent", "all"} + + +@pytest.mark.asyncio +class TestMCPGetThemeSchema: + async def test_get_theme_schema_returns_vocabulary(self, client): + resp = await client.get("/api/mcp/tools/get_theme_schema") + assert resp.status_code == 200 + data = resp.json() + assert "tokens" in data + assert "structure" in data + assert "effects" in data + assert "safety_floor" in data + assert "asset_limits" in data + assert isinstance(data["tokens"], list) + assert len(data["tokens"]) > 0 + assert isinstance(data["structure"], dict) From b778408f5e13152f69c35385d8c1c77f3ccf5ede Mon Sep 17 00:00:00 2001 From: jaylfc Date: Tue, 11 Aug 2026 18:18:26 +0000 Subject: [PATCH 47/56] refactor(wallpaper-fit): drop inert helper/prop threading, real fallback tests, changelog fragment - Remove wallpaperFitToClass: exported but used only by its own test; the real fit mapping lives in tokens.css attribute selectors. - Desktop reads wallpaperFit from the theme store like its other theme fields; the letterboxBg prop always equaled the fallback Desktop already computes, so the prop threading added nothing. - Drop the two background-color: var(--wallpaper-fallback) rules: that custom property is defined nowhere, and the inline fallback background on every .taos-wallpaper element wins over the sheet anyway. - Remove the duplicate device-id mint in App.tsx (getDeviceId mints lazily at store init, before the effect could run). - Make the default/invalid-value tests call loadWallpaperFit instead of asserting state they had just set; drop two tests that only exercised local string concatenation. Invalid-value test proven red against a validation-skipping mutation. - Restore the square-display comment in tokens.css; add the changelog fragment doc-gate asks for. --- changelog.d/2357-wallpaper-fit-options.md | 6 +++ desktop/src/App.tsx | 12 +---- desktop/src/components/Desktop.tsx | 6 +-- .../stores/__tests__/wallpaper-fit.test.ts | 52 +------------------ desktop/src/stores/theme-store.ts | 3 -- desktop/src/theme/tokens.css | 21 ++++---- 6 files changed, 24 insertions(+), 76 deletions(-) create mode 100644 changelog.d/2357-wallpaper-fit-options.md diff --git a/changelog.d/2357-wallpaper-fit-options.md b/changelog.d/2357-wallpaper-fit-options.md new file mode 100644 index 000000000..bd50e3030 --- /dev/null +++ b/changelog.d/2357-wallpaper-fit-options.md @@ -0,0 +1,6 @@ +### Added + +- Wallpaper fit options in Settings → Desktop & Dock: fill, fit, stretch, + center, and tile. The choice is persisted per device (localStorage, keyed + by a locally minted device id that is never sent to the server), so each + screen keeps the fit that suits its aspect ratio (#2357). diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index 809af8702..122c65d3b 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -259,16 +259,6 @@ export function App() { // box (#58). An explicit user choice is always honored and never overridden. usePerfAutoDetect(); - // Persist a stable per-browser-profile device id (never sent to the server) - // so the per-device wallpaper-fit preference is isolated from other devices. - useEffect(() => { - const KEY = "taos-wallpaper-device-id"; - if (typeof window === "undefined") return; - if (!localStorage.getItem(KEY)) { - localStorage.setItem(KEY, crypto.randomUUID()); - } - }, []); - // Sync the persistent backend notification feed into the bell (desktop and // mobile both render NotificationCentre under this component). useServerNotifications(); @@ -395,7 +385,7 @@ export function App() {
- + setLaunchpadOpen(false)} onOpenApp={(wid) => setActiveWindowId(wid)} /> setSearchOpen(false)} onOpenApp={(wid) => setActiveWindowId(wid)} /> diff --git a/desktop/src/components/Desktop.tsx b/desktop/src/components/Desktop.tsx index 7affd39cc..534037527 100644 --- a/desktop/src/components/Desktop.tsx +++ b/desktop/src/components/Desktop.tsx @@ -23,7 +23,7 @@ type ContextMenuState = { y: number; } | null; -export function Desktop({ wallpaperFit, letterboxBg }: { wallpaperFit?: string; letterboxBg?: string }) { +export function Desktop() { const windows = useProcessStore((s) => s.windows); const { openWindow, reclampAllWindows } = useProcessStore(); const wallpaperImage = useThemeStore((s) => s.wallpaperImage); @@ -38,13 +38,13 @@ export function Desktop({ wallpaperFit, letterboxBg }: { wallpaperFit?: string; const wallpaperOverlayText = useThemeStore((s) => s.wallpaperOverlayText); const showOverlayText = useThemeStore((s) => s.showOverlayText); const reduceEffects = useThemeStore((s) => s.reduceEffects); + const wallpaperFit = useThemeStore((s) => s.wallpaperFit); const isAnimated = wallpaperKind === "animated"; // Invert the wallpaper with the theme: use the light variant when present. const useLight = scheme === "light" && !!wallpaperLightImage; const effImage = useLight ? wallpaperLightImage : wallpaperImage; const effMobile = useLight ? wallpaperLightMobileImage : wallpaperMobileImage; const effFallback = useLight ? wallpaperLightFallback : wallpaperFallback; - const bgColor = letterboxBg ?? effFallback; const { showWidgets, toggleWidgets } = useWidgetStore(); const [contextMenu, setContextMenu] = useState(null); const [wallpaperPickerOpen, setWallpaperPickerOpen] = useState(false); @@ -159,7 +159,7 @@ export function Desktop({ wallpaperFit, letterboxBg }: { wallpaperFit?: string; return (
{ useThemeStore.setState({ @@ -39,32 +34,6 @@ describe("wallpaper-fit (per-device, CSS-driven)", () => { localStorage.clear(); }); - /* ------------------------------------------------------------------ */ - /* wallpaperFitToClass — the fit-to-CSS mapping */ - /* ------------------------------------------------------------------ */ - - describe("wallpaperFitToClass", () => { - const expected: Record = { - fill: 'data-wallpaper-fit="fill"', - fit: 'data-wallpaper-fit="fit"', - stretch: 'data-wallpaper-fit="stretch"', - center: 'data-wallpaper-fit="center"', - tile: 'data-wallpaper-fit="tile"', - }; - - it.each(WALLPAPER_FIT_OPTIONS)( - "maps %s to its CSS data attribute", - (fit) => { - expect(wallpaperFitToClass(fit)).toBe(expected[fit]); - } - ); - - it("returns a distinct value for every option", () => { - const classes = WALLPAPER_FIT_OPTIONS.map(wallpaperFitToClass); - expect(new Set(classes).size).toBe(WALLPAPER_FIT_OPTIONS.length); - }); - }); - /* ------------------------------------------------------------------ */ /* Per-device persistence */ /* ------------------------------------------------------------------ */ @@ -105,15 +74,13 @@ describe("wallpaper-fit (per-device, CSS-driven)", () => { it("defaults to fill when no preference is stored", () => { localStorage.clear(); - useThemeStore.setState({ wallpaperFit: "fill" }); - expect(useThemeStore.getState().wallpaperFit).toBe("fill"); + expect(loadWallpaperFit()).toBe("fill"); }); it("ignores an invalid stored value and falls back to fill", () => { localStorage.setItem("taos-wallpaper-device-id", "bad-device"); localStorage.setItem("taos-wallpaper-fit:bad-device", "not-a-real-fit"); - useThemeStore.setState({ wallpaperFit: "fill" }); - expect(useThemeStore.getState().wallpaperFit).toBe("fill"); + expect(loadWallpaperFit()).toBe("fill"); }); /* ------------------------------------------------------------------ */ @@ -133,19 +100,4 @@ describe("wallpaper-fit (per-device, CSS-driven)", () => { useThemeStore.getState().setWallpaperFit("fill"); expect(useThemeStore.getState().wallpaperFit).toBe("fill"); }); - - /* ------------------------------------------------------------------ */ - /* Key derivation — per-device isolation at the key level */ - /* ------------------------------------------------------------------ */ - - it("derives a different localStorage key for each device id", () => { - const ids = ["alpha", "bravo", "charlie"]; - const keys = ids.map((id) => "taos-wallpaper-fit:" + id); - expect(new Set(keys).size).toBe(3); - }); - - it("does not include the server-synced user id in the localStorage key", () => { - const key = "taos-wallpaper-fit:" + localStorage.getItem("taos-wallpaper-device-id")!; - expect(key).not.toContain("taos.user.id"); - }); }); diff --git a/desktop/src/stores/theme-store.ts b/desktop/src/stores/theme-store.ts index 5908f35e0..244159e1b 100644 --- a/desktop/src/stores/theme-store.ts +++ b/desktop/src/stores/theme-store.ts @@ -207,9 +207,6 @@ const WALLPAPER_FIT_KEY = "taos-wallpaper-fit:"; function wallpaperFitKey(): string { return WALLPAPER_FIT_KEY + getDeviceId(); } -export function wallpaperFitToClass(fit: WallpaperFit): string { - return `data-wallpaper-fit="${fit}"`; -} export function loadWallpaperFit(): WallpaperFit { try { const raw = localStorage.getItem(wallpaperFitKey()); diff --git a/desktop/src/theme/tokens.css b/desktop/src/theme/tokens.css index af99f07d8..99b2f7942 100644 --- a/desktop/src/theme/tokens.css +++ b/desktop/src/theme/tokens.css @@ -188,6 +188,15 @@ background-size: cover; } @media (max-width: 767px), (orientation: portrait) { + /* On phones we switch to a portrait-cropped image if the wallpaper + provides one. + + Sizing is `cover`, NOT `100% 100%`. Stretching both axes distorted the + image on any display whose aspect ratio did not match the asset, and + `(orientation: portrait)` matches every screen where height >= width, + which includes SQUARE displays: a square device took the phone branch and + had a wide wallpaper squashed into it (reported 2026-08-02). `cover` fills + the screen and crops the overflow instead of deforming the picture. */ .taos-wallpaper { background-image: var(--wallpaper-mobile, var(--wallpaper-desktop)); background-size: cover; @@ -212,15 +221,9 @@ background-size: auto; background-repeat: repeat; } - -/* Letterbox bars for fit and center — use the wallpaper fallback so bars - blend with the image instead of showing a jarring default colour. */ -.taos-wallpaper[data-wallpaper-fit="fit"] { - background-color: var(--wallpaper-fallback); -} -.taos-wallpaper[data-wallpaper-fit="center"] { - background-color: var(--wallpaper-fallback); -} +/* Letterbox bars for fit/center need no rule here: every .taos-wallpaper + element sets the wallpaper fallback colour as an inline background, and + an inline style always wins over anything this sheet could declare. */ /* React-grid-layout dark theme overrides for widget system */ .react-grid-item.react-grid-placeholder { From 0b632ebbfa1a864db92eadf661005d72732befa7 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Tue, 11 Aug 2026 18:30:48 +0000 Subject: [PATCH 48/56] fix(registry): existence-hiding 404 on the remaining owner-gated write routes Sweep of the same class the scope-request routes fixed: PATCH, DELETE, rotate-tokens, and org PUT all returned 404-if-missing then 403-if-not-owner, disclosing id existence to any authenticated non-owner. All four now return the not-found response for non-owners, using the exact idiom of the GET route this file already documents as the reference implementation. Lifecycle routes (_transition) and the consent approve/deny routes check admin BEFORE any lookup, so they respond uniformly already and are unchanged. Byte-identical tests (status + body, non-owner vs nonexistent) for all four routes with a real non-admin user; PATCH test proven red against the old require_owner_or_admin behaviour. The import that check left orphaned is removed. Changelog fragment added for doc-gate. --- changelog.d/2356-registry-existence-hiding.md | 7 ++ tests/test_agent_registry.py | 85 +++++++++++++++++++ tinyagentos/routes/agent_registry.py | 20 +++-- 3 files changed, 105 insertions(+), 7 deletions(-) create mode 100644 changelog.d/2356-registry-existence-hiding.md diff --git a/changelog.d/2356-registry-existence-hiding.md b/changelog.d/2356-registry-existence-hiding.md new file mode 100644 index 000000000..7ce8f5f25 --- /dev/null +++ b/changelog.d/2356-registry-existence-hiding.md @@ -0,0 +1,7 @@ +### Security + +- All owner-gated agent-registry routes are now existence-hiding: a caller who + does not own an agent gets the same 404 as a nonexistent id, on the + scope-request create/approve/deny routes and on registry PATCH, revoke, + rotate-tokens, and org update. Previously a 403-vs-404 difference disclosed + whether an agent id existed (issue #2106, reported by hognek) (#2356). diff --git a/tests/test_agent_registry.py b/tests/test_agent_registry.py index d40b12bb8..dd1000e11 100644 --- a/tests/test_agent_registry.py +++ b/tests/test_agent_registry.py @@ -829,3 +829,88 @@ def test_allowed_scopes_includes_project_doc_review(): def test_allowed_scopes_includes_observatory_control(): """observatory_control must be in the mint allowlist so internal agents can be granted it.""" assert "observatory_control" in _ALLOWED_SCOPES + + +# --------------------------------------------------------------------------- +# Existence-hiding: non-owner vs non-existent must be byte-identical +# (same contract the scope-request routes assert in +# tests/test_agent_scope_requests.py; GET already had it, these four +# write routes gained it in the same pass) +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +class TestRegistryWriteExistenceHiding: + async def _non_owner_client(self, app): + """Session client for a real NON-admin user who owns nothing.""" + code = app.state.auth.add_user_invite("mallory", "admin") + app.state.auth.complete_invite("mallory", code, "Mallory", "", "malpass123") + record = app.state.auth.find_user("mallory") + session = app.state.auth.create_session(user_id=record["id"], long_lived=True) + return AsyncClient( + transport=ASGITransport(app=app), + base_url="http://test", + cookies={"taos_session": session}, + ) + + async def _register(self, registry_client): + resp = await registry_client.post( + "/api/agents/registry/register", + json={"framework": "openclaw", "display_name": "Hidden Agent"}, + ) + assert resp.status_code == 200 + return resp.json()["canonical_id"] + + async def test_patch_non_owner_and_nonexistent_identical(self, app, registry_client): + cid = await self._register(registry_client) + async with await self._non_owner_client(app) as mallory: + resp_owned = await mallory.patch( + f"/api/agents/registry/{cid}", json={"display_name": "Stolen"} + ) + resp_missing = await mallory.patch( + "/api/agents/registry/does-not-exist", json={"display_name": "Stolen"} + ) + assert resp_owned.status_code == resp_missing.status_code == 404 + assert resp_owned.json() == resp_missing.json() + # And the record was not modified. + check = await registry_client.get(f"/api/agents/registry/{cid}") + assert check.json()["display_name"] == "Hidden Agent" + + async def test_delete_non_owner_and_nonexistent_identical(self, app, registry_client): + cid = await self._register(registry_client) + async with await self._non_owner_client(app) as mallory: + resp_owned = await mallory.delete(f"/api/agents/registry/{cid}") + resp_missing = await mallory.delete("/api/agents/registry/does-not-exist") + assert resp_owned.status_code == resp_missing.status_code == 404 + assert resp_owned.json() == resp_missing.json() + # And the record was not revoked. + check = await registry_client.get(f"/api/agents/registry/{cid}") + assert check.status_code == 200 + assert not check.json().get("revoked_at") + + async def test_rotate_tokens_non_owner_and_nonexistent_identical( + self, app, registry_client + ): + cid = await self._register(registry_client) + async with await self._non_owner_client(app) as mallory: + resp_owned = await mallory.post( + f"/api/agents/registry/{cid}/rotate-tokens" + ) + resp_missing = await mallory.post( + "/api/agents/registry/does-not-exist/rotate-tokens" + ) + assert resp_owned.status_code == resp_missing.status_code == 404 + assert resp_owned.json() == resp_missing.json() + + async def test_org_put_non_owner_and_nonexistent_identical( + self, app, registry_client + ): + cid = await self._register(registry_client) + async with await self._non_owner_client(app) as mallory: + resp_owned = await mallory.put( + f"/api/agents/{cid}/org", json={"role": "usurper"} + ) + resp_missing = await mallory.put( + "/api/agents/does-not-exist/org", json={"role": "usurper"} + ) + assert resp_owned.status_code == resp_missing.status_code == 404 + assert resp_owned.json() == resp_missing.json() diff --git a/tinyagentos/routes/agent_registry.py b/tinyagentos/routes/agent_registry.py index a175c1a7e..c3de1e2f0 100644 --- a/tinyagentos/routes/agent_registry.py +++ b/tinyagentos/routes/agent_registry.py @@ -33,7 +33,7 @@ from tinyagentos.agent_registry_store import mint_registry_token from tinyagentos.agent_token_auth import check_agent_scope -from tinyagentos.auth_context import CurrentUser, current_user, require_owner_or_admin +from tinyagentos.auth_context import CurrentUser, current_user logger = logging.getLogger(__name__) @@ -612,13 +612,15 @@ async def patch_registry_entry( Allowed fields: display_name, handle, role, capabilities. Status, framework, user_id, and timestamps are immutable. - Only the owning user or an admin may update an entry. + Only the owning user or an admin may update an entry; anyone else gets + the same 404 as an unknown id (existence-hiding, as on GET). """ store = _get_store(request) record = await store.get(canonical_id) if record is None: return JSONResponse({"error": "not found"}, status_code=404) - require_owner_or_admin(user, record["user_id"]) + if not user.is_admin and user.user_id != record["user_id"]: + return JSONResponse({"error": "not found"}, status_code=404) old_name = record.get("display_name") or "" try: updated = await store.update( @@ -664,13 +666,15 @@ async def revoke_registry_entry( ): """Revoke a registry entry (sets revoked_at, does not delete). - Only the owning user or an admin may revoke an entry. + Only the owning user or an admin may revoke an entry; anyone else gets + the same 404 as an unknown id (existence-hiding, as on GET). """ store = _get_store(request) record = await store.get(canonical_id) if record is None: return JSONResponse({"error": "not found or already revoked"}, status_code=404) - require_owner_or_admin(user, record["user_id"]) + if not user.is_admin and user.user_id != record["user_id"]: + return JSONResponse({"error": "not found or already revoked"}, status_code=404) before_status = record.get("status") or "active" revoked = await store.revoke(canonical_id) await _audit_governance( @@ -778,7 +782,8 @@ async def rotate_tokens( record = await store.get(canonical_id) if record is None: return JSONResponse({"error": "not found"}, status_code=404) - require_owner_or_admin(user, record["user_id"]) + if not user.is_admin and user.user_id != record["user_id"]: + return JSONResponse({"error": "not found"}, status_code=404) ts = int(time.time()) before_iat = record.get("token_min_iat") or 0 @@ -841,7 +846,8 @@ async def update_org_fields( record = await store.get(canonical_id) if record is None: return JSONResponse({"error": "not found"}, status_code=404) - require_owner_or_admin(user, record["user_id"]) + if not user.is_admin and user.user_id != record["user_id"]: + return JSONResponse({"error": "not found"}, status_code=404) if body.role is None and body.title is None and body.reports_to is None: # An all-None body is a no-op write; reject it rather than returning a From 74ba0d29a1da88a5656b92d897bb398ab647da58 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Tue, 11 Aug 2026 18:47:57 +0000 Subject: [PATCH 49/56] test(registry): update remaining 403 assertions to existence-hiding 404, document contract The class sweep changed four write routes but missed four pre-existing tests asserting the old 403 in OTHER files (caught by CI shards): two rotate-tokens tests, the lifecycle PATCH non-owner test, and the org PUT non-owner test. All now assert the not-found 404 with docstrings explaining why. docs/agent-coordination.md gains the existence-hiding contract for the whole owner-gated registry surface (doc-gate agent-manual rule), including the warning that a 404 no longer proves nonexistence. --- docs/agent-coordination.md | 9 +++++++++ tests/test_registry_governance_lifecycle.py | 4 ++-- tests/test_routes_agent_org.py | 2 +- tests/test_token_rotation.py | 14 +++++++------- 4 files changed, 19 insertions(+), 10 deletions(-) diff --git a/docs/agent-coordination.md b/docs/agent-coordination.md index 0a1b52358..a01af46ec 100644 --- a/docs/agent-coordination.md +++ b/docs/agent-coordination.md @@ -396,6 +396,15 @@ that SAME canonical_id instead: - `POST /api/agents/registry/{canonical_id}/scope-requests/{req_id}/deny`: owner/admin only. +All owner-gated registry routes are existence-hiding (#2106): an authenticated +caller who is not the owner gets the same 404 body as a nonexistent +`canonical_id`, on the scope-request create/approve/deny routes above and on +registry PATCH, DELETE (revoke), rotate-tokens, and `PUT /api/agents/{id}/org`. +Agents must not treat a 404 from these routes as proof an id does not exist, +and must not expect a 403 to distinguish "exists, not yours". Admin-only +lifecycle routes (approve/reject/suspend/reactivate) still 403 non-admins +before any lookup, which discloses nothing. + Requested scopes are validated against the same closed `VALID_SCOPES` vocabulary as the consent flow. `project_tasks` and the canvas scopes still require an explicit `project_id`; `decisions_read` / `decisions_write` (and the other global diff --git a/tests/test_registry_governance_lifecycle.py b/tests/test_registry_governance_lifecycle.py index a0c2b87e8..735588f3a 100644 --- a/tests/test_registry_governance_lifecycle.py +++ b/tests/test_registry_governance_lifecycle.py @@ -846,12 +846,12 @@ async def test_patch_by_non_owner_member_returns_403( user_id="other-user-uid", ) cid = rec["canonical_id"] - # member tries to patch another user's entry → 403 + # member tries to patch another user's entry → 404 (existence-hiding) resp = await gov_member_client.patch( f"/api/agents/registry/{cid}", json={"display_name": "Hijacked"}, ) - assert resp.status_code == 403 + assert resp.status_code == 404 async def test_patch_empty_body_is_noop(self, gov_client, tmp_data_dir): client, _ = gov_client diff --git a/tests/test_routes_agent_org.py b/tests/test_routes_agent_org.py index 295849d14..282a5cb7c 100644 --- a/tests/test_routes_agent_org.py +++ b/tests/test_routes_agent_org.py @@ -258,4 +258,4 @@ async def test_member_cannot_update_others_entry(self, org_client, app): ) finally: await member_client.aclose() - assert resp.status_code == 403 + assert resp.status_code == 404 diff --git a/tests/test_token_rotation.py b/tests/test_token_rotation.py index 20c99c38b..3a3b53aae 100644 --- a/tests/test_token_rotation.py +++ b/tests/test_token_rotation.py @@ -241,7 +241,7 @@ async def test_nonadmin_owner_can_rotate_own_identity(self, agent_app, app): @pytest.mark.asyncio async def test_nonadmin_cannot_rotate_others_agent(self, agent_app, app): - """A non-admin, non-owner rotating someone ELSE'S agent → 403.""" + """A non-admin, non-owner rotating someone ELSE'S agent → 404 (existence-hiding).""" # First user owns an agent. owner_client, owner_uid = _make_nonadmin_client( app, app.state.auth, username="owner2", full_name="Owner Two", @@ -260,22 +260,22 @@ async def test_nonadmin_cannot_rotate_others_agent(self, agent_app, app): resp = await intruder_client.post( f"/api/agents/registry/{cid}/rotate-tokens" ) - assert resp.status_code == 403 + assert resp.status_code == 404 @pytest.mark.asyncio async def test_empty_userid_agent_is_admin_only(self, agent_app, app): """An agent_registry row with user_id='' can ONLY be rotated by admin. - Non-admin sessions get 403 because require_owner_or_admin compares - the session's user_id against an empty string (owner match fails) - and the session is not admin. + Non-admin sessions get 404 (existence-hiding): the owner match + against an empty string fails, the session is not admin, and the + route answers exactly as it would for an unknown id. """ # Register an agent with the default user_id="" (admin-only). cid, _token = await _register_and_mint(app, user_id="admin") r = await app.state.agent_registry.get(cid) assert r["user_id"] == "" - # A non-admin session trying to rotate it must get 403. + # A non-admin session trying to rotate it must get the not-found 404. nonadmin_client, _uid = _make_nonadmin_client( app, app.state.auth, username="randouser", full_name="Rando", password="password123", @@ -284,7 +284,7 @@ async def test_empty_userid_agent_is_admin_only(self, agent_app, app): resp = await nonadmin_client.post( f"/api/agents/registry/{cid}/rotate-tokens" ) - assert resp.status_code == 403 + assert resp.status_code == 404 @pytest.mark.asyncio async def test_rotate_nonexistent_returns_404(self, agent_app): From 999f7ab899b2f54e159e892037a837b1f43ef57b Mon Sep 17 00:00:00 2001 From: jaylfc Date: Tue, 11 Aug 2026 19:12:43 +0000 Subject: [PATCH 50/56] test(registry): byte-identical .content comparisons, same-caller probes, route logs Folds the Kilo + CodeRabbit findings on the identical-response tests: .json() compares normalized objects so it cannot back the byte-identical claim; all seven tests now compare resp.content. The three scope-request tests also send their nonexistent probe from the SAME non-owner client instead of the admin fixture, since the contract under test is what one unprivileged caller can distinguish. The four registry write routes gain the same unknown-vs-not-owner server-side logs the scope-request routes already emit. --- tests/test_agent_registry.py | 8 +++---- tests/test_agent_scope_requests.py | 34 ++++++++++++++-------------- tinyagentos/routes/agent_registry.py | 8 +++++++ 3 files changed, 29 insertions(+), 21 deletions(-) diff --git a/tests/test_agent_registry.py b/tests/test_agent_registry.py index dd1000e11..1073d9c48 100644 --- a/tests/test_agent_registry.py +++ b/tests/test_agent_registry.py @@ -870,7 +870,7 @@ async def test_patch_non_owner_and_nonexistent_identical(self, app, registry_cli "/api/agents/registry/does-not-exist", json={"display_name": "Stolen"} ) assert resp_owned.status_code == resp_missing.status_code == 404 - assert resp_owned.json() == resp_missing.json() + assert resp_owned.content == resp_missing.content # And the record was not modified. check = await registry_client.get(f"/api/agents/registry/{cid}") assert check.json()["display_name"] == "Hidden Agent" @@ -881,7 +881,7 @@ async def test_delete_non_owner_and_nonexistent_identical(self, app, registry_cl resp_owned = await mallory.delete(f"/api/agents/registry/{cid}") resp_missing = await mallory.delete("/api/agents/registry/does-not-exist") assert resp_owned.status_code == resp_missing.status_code == 404 - assert resp_owned.json() == resp_missing.json() + assert resp_owned.content == resp_missing.content # And the record was not revoked. check = await registry_client.get(f"/api/agents/registry/{cid}") assert check.status_code == 200 @@ -899,7 +899,7 @@ async def test_rotate_tokens_non_owner_and_nonexistent_identical( "/api/agents/registry/does-not-exist/rotate-tokens" ) assert resp_owned.status_code == resp_missing.status_code == 404 - assert resp_owned.json() == resp_missing.json() + assert resp_owned.content == resp_missing.content async def test_org_put_non_owner_and_nonexistent_identical( self, app, registry_client @@ -913,4 +913,4 @@ async def test_org_put_non_owner_and_nonexistent_identical( "/api/agents/does-not-exist/org", json={"role": "usurper"} ) assert resp_owned.status_code == resp_missing.status_code == 404 - assert resp_owned.json() == resp_missing.json() + assert resp_owned.content == resp_missing.content diff --git a/tests/test_agent_scope_requests.py b/tests/test_agent_scope_requests.py index 2e55ff6d8..6c1fa02fd 100644 --- a/tests/test_agent_scope_requests.py +++ b/tests/test_agent_scope_requests.py @@ -629,14 +629,16 @@ async def test_create_scope_request_non_owner_and_nonexistent_identical( f"/api/agents/registry/{cid}/scope-requests", json={"requested_scopes": ["memory_read"]}, ) - - resp_nonexistent = await client.post( - "/api/agents/registry/does-not-exist/scope-requests", - json={"requested_scopes": ["memory_read"]}, - ) + # Same caller for the nonexistent probe: an admin would 404 on a + # missing id too, but the contract under test is what ONE + # unprivileged caller can distinguish. + resp_nonexistent = await carol_client.post( + "/api/agents/registry/does-not-exist/scope-requests", + json={"requested_scopes": ["memory_read"]}, + ) assert resp_owner.status_code == resp_nonexistent.status_code == 404 - assert resp_owner.json() == resp_nonexistent.json() + assert resp_owner.content == resp_nonexistent.content finally: await env.close() @@ -669,14 +671,13 @@ async def test_approve_scope_request_non_owner_and_nonexistent_identical( f"/api/agents/registry/{cid}/scope-requests/{rec['id']}/approve", json={"granted_scopes": ["memory_read"]}, ) - - resp_nonexistent = await client.post( - "/api/agents/registry/does-not-exist/scope-requests/does-not-exist/approve", - json={"granted_scopes": ["memory_read"]}, - ) + resp_nonexistent = await carol_client.post( + "/api/agents/registry/does-not-exist/scope-requests/does-not-exist/approve", + json={"granted_scopes": ["memory_read"]}, + ) assert resp_owner.status_code == resp_nonexistent.status_code == 404 - assert resp_owner.json() == resp_nonexistent.json() + assert resp_owner.content == resp_nonexistent.content finally: await env.close() @@ -708,12 +709,11 @@ async def test_deny_scope_request_non_owner_and_nonexistent_identical( resp_owner = await carol_client.post( f"/api/agents/registry/{cid}/scope-requests/{rec['id']}/deny", ) - - resp_nonexistent = await client.post( - "/api/agents/registry/does-not-exist/scope-requests/does-not-exist/deny", - ) + resp_nonexistent = await carol_client.post( + "/api/agents/registry/does-not-exist/scope-requests/does-not-exist/deny", + ) assert resp_owner.status_code == resp_nonexistent.status_code == 404 - assert resp_owner.json() == resp_nonexistent.json() + assert resp_owner.content == resp_nonexistent.content finally: await env.close() diff --git a/tinyagentos/routes/agent_registry.py b/tinyagentos/routes/agent_registry.py index c3de1e2f0..98964be18 100644 --- a/tinyagentos/routes/agent_registry.py +++ b/tinyagentos/routes/agent_registry.py @@ -618,8 +618,10 @@ async def patch_registry_entry( store = _get_store(request) record = await store.get(canonical_id) if record is None: + logger.info("registry patch 404-unknown for %s", canonical_id) return JSONResponse({"error": "not found"}, status_code=404) if not user.is_admin and user.user_id != record["user_id"]: + logger.info("registry patch 404-not-owner for %s by %s", canonical_id, user.user_id) return JSONResponse({"error": "not found"}, status_code=404) old_name = record.get("display_name") or "" try: @@ -672,8 +674,10 @@ async def revoke_registry_entry( store = _get_store(request) record = await store.get(canonical_id) if record is None: + logger.info("registry revoke 404-unknown for %s", canonical_id) return JSONResponse({"error": "not found or already revoked"}, status_code=404) if not user.is_admin and user.user_id != record["user_id"]: + logger.info("registry revoke 404-not-owner for %s by %s", canonical_id, user.user_id) return JSONResponse({"error": "not found or already revoked"}, status_code=404) before_status = record.get("status") or "active" revoked = await store.revoke(canonical_id) @@ -781,8 +785,10 @@ async def rotate_tokens( store = _get_store(request) record = await store.get(canonical_id) if record is None: + logger.info("registry rotate-tokens 404-unknown for %s", canonical_id) return JSONResponse({"error": "not found"}, status_code=404) if not user.is_admin and user.user_id != record["user_id"]: + logger.info("registry rotate-tokens 404-not-owner for %s by %s", canonical_id, user.user_id) return JSONResponse({"error": "not found"}, status_code=404) ts = int(time.time()) @@ -845,8 +851,10 @@ async def update_org_fields( store = _get_store(request) record = await store.get(canonical_id) if record is None: + logger.info("registry org update 404-unknown for %s", canonical_id) return JSONResponse({"error": "not found"}, status_code=404) if not user.is_admin and user.user_id != record["user_id"]: + logger.info("registry org update 404-not-owner for %s by %s", canonical_id, user.user_id) return JSONResponse({"error": "not found"}, status_code=404) if body.role is None and body.title is None and body.reports_to is None: From e44e7216d17eb2d80852b0703778bfc0094592d2 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Tue, 11 Aug 2026 19:36:53 +0000 Subject: [PATCH 51/56] fix(scope-requests): close the credential-error existence oracle on create check_agent_identity raises 401 (malformed token) or 403 (inactive agent) in-route, but create_scope_request 404s on an unknown target BEFORE auth runs, so a caller holding a bad token could distinguish existing targets (401/403) from nonexistent ones (404). The authorize helper now converts those raises into the uniform 404, logging the true cause server-side. Regression test: a suspended agent's validly signed token gets byte-identical 404s for an existing and a nonexistent target; proven red against the unguarded call. Also renames the create/approve/deny log labels from 403-not-owner to 404-not-owner to match what the routes actually return (CodeRabbit findings, both folded). --- tests/test_agent_scope_requests.py | 38 +++++++++++++++++++++++ tinyagentos/routes/agent_auth_requests.py | 21 ++++++++++--- 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/tests/test_agent_scope_requests.py b/tests/test_agent_scope_requests.py index 6c1fa02fd..00970100f 100644 --- a/tests/test_agent_scope_requests.py +++ b/tests/test_agent_scope_requests.py @@ -717,3 +717,41 @@ async def test_deny_scope_request_non_owner_and_nonexistent_identical( assert resp_owner.content == resp_nonexistent.content finally: await env.close() + + +@pytest.mark.asyncio +async def test_create_scope_request_inactive_token_no_existence_oracle( + client, monkeypatch, tmp_path +): + """A suspended agent's (validly signed) token must get the SAME response + for an existing target as for a nonexistent one. Before the fix, + check_agent_identity's 403 surfaced only when the target existed (an + unknown target 404s first), disclosing existence through the + credential-error path.""" + env = await _wire(client, monkeypatch, tmp_path) + try: + cid_target = await _register_active(env, handle="@target", display="target") + cid_b = await _register_active(env, handle="@suspended", display="suspended") + token_b = env.agent_token(cid_b) + await env.registry.set_status(cid_b, "suspended", actor="test") + + app = client._transport.app + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as bare: + resp_existing = await bare.post( + f"/api/agents/registry/{cid_target}/scope-requests", + headers={"Authorization": f"Bearer {token_b}"}, + json={"requested_scopes": ["a2a_send"]}, + ) + resp_missing = await bare.post( + "/api/agents/registry/does-not-exist/scope-requests", + headers={"Authorization": f"Bearer {token_b}"}, + json={"requested_scopes": ["a2a_send"]}, + ) + + assert resp_existing.status_code == resp_missing.status_code == 404 + assert resp_existing.content == resp_missing.content + assert await env.scope_store.count_pending_for(cid_target) == 0 + finally: + await env.close() diff --git a/tinyagentos/routes/agent_auth_requests.py b/tinyagentos/routes/agent_auth_requests.py index 8aa0c2bae..50842548b 100644 --- a/tinyagentos/routes/agent_auth_requests.py +++ b/tinyagentos/routes/agent_auth_requests.py @@ -961,12 +961,25 @@ async def _authorize_scope_request_creation( from tinyagentos.agent_token_auth import check_agent_identity - agent_cid = await check_agent_identity(request) + try: + agent_cid = await check_agent_identity(request) + except HTTPException as exc: + # A malformed or inactive-agent token must learn no more than an + # anonymous caller: letting check_agent_identity's 401/403 surface + # here would pair with the earlier 404-on-unknown to form an + # existence oracle (unknown target 404, existing target 401/403). + logger.info( + "scope request create 404-bad-credentials for %s (%s)", + canonical_id, exc.detail, + ) + raise HTTPException( + status_code=404, detail="agent not found or not active" + ) from None if agent_cid is not None and agent_cid == canonical_id: return logger.info( - "scope request create 403-not-owner for %s by %s", + "scope request create 404-not-owner for %s by %s", canonical_id, uid, ) raise HTTPException(status_code=404, detail="agent not found or not active") @@ -1073,7 +1086,7 @@ async def approve_scope_request( raise HTTPException(status_code=404, detail="agent not found or not active") if not (user.is_admin or user.user_id == record["user_id"]): logger.info( - "scope request approve 403-not-owner for %s by %s", + "scope request approve 404-not-owner for %s by %s", canonical_id, user.user_id, ) raise HTTPException(status_code=404, detail="agent not found or not active") @@ -1199,7 +1212,7 @@ async def deny_scope_request( raise HTTPException(status_code=404, detail="agent not found") if not (user.is_admin or user.user_id == record["user_id"]): logger.info( - "scope request deny 403-not-owner for %s by %s", + "scope request deny 404-not-owner for %s by %s", canonical_id, user.user_id, ) raise HTTPException(status_code=404, detail="agent not found") From 2c5c8a380faa315363f50e4b2c54cd0ede9ed6c3 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Tue, 11 Aug 2026 20:12:58 +0000 Subject: [PATCH 52/56] fix(lists): cursor-scope in get_entry, rollback on failed reorder (tsk-u23vjy) Re-scoped fix-forward of closed duplicate PR #2183. Of the card's four defects, two are already resolved or impossible on dev: position-0 collision was fixed by #2265's atomic in-INSERT allocation (existing concurrency test guards it), and NULL list_id rows cannot exist (schema NOT NULL). The two real ones land here: - get_entry read cur.description outside the cursor context; moved inside. - reorder_entries left already-issued UPDATEs pending when one raised, so the next unrelated commit() flushed a half-applied reorder; now rolls back and re-raises, with a test proving the pending write neither survives immediately nor resurfaces via a later unrelated commit (proven red against the unguarded store). --- .../2361-lists-store-cursor-rollback.md | 6 ++ tests/projects/test_lists_store.py | 68 +++++++++++++++++++ tinyagentos/projects/lists_store.py | 30 +++++--- 3 files changed, 93 insertions(+), 11 deletions(-) create mode 100644 changelog.d/2361-lists-store-cursor-rollback.md diff --git a/changelog.d/2361-lists-store-cursor-rollback.md b/changelog.d/2361-lists-store-cursor-rollback.md new file mode 100644 index 000000000..080159d0d --- /dev/null +++ b/changelog.d/2361-lists-store-cursor-rollback.md @@ -0,0 +1,6 @@ +### Fixed + +- Project list entries: `get_entry` no longer reads cursor metadata after the + cursor closes, and a failed reorder now rolls back its partial updates so a + later unrelated write cannot commit a half-applied ordering (tsk-u23vjy, + fix-forward of #2183). diff --git a/tests/projects/test_lists_store.py b/tests/projects/test_lists_store.py index d42a56569..0c186063c 100644 --- a/tests/projects/test_lists_store.py +++ b/tests/projects/test_lists_store.py @@ -327,3 +327,71 @@ async def slow_next_position(project_id, list_id): positions = {e1["position"], e2["position"]} assert len(positions) == 2, f"expected distinct positions, got {positions}" + + +# --------------------------------------------------------------------------- +# tsk-u23vjy (fix-forward of the closed duplicate PR #2183, re-scoped to what +# is real on dev): of the card's four defects, position-0 collision is already +# fixed by the atomic INSERT (#2265, concurrency test above) and NULL list_id +# rows are impossible (schema: list_id TEXT NOT NULL). The two below remain. +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_get_entry_reads_description_before_cursor_closes(entries_store): + e = await entries_store.add_entry( + list_id="lst-1", project_id="prj-1", text="Test entry", + original_text="Test entry", author_kind="agent", author_id="agent-1", + ) + result = await entries_store.get_entry(e["id"]) + assert result is not None + assert result["id"] == e["id"] + assert result["text"] == "Test entry" + + +@pytest.mark.asyncio +async def test_reorder_entries_rolls_back_on_exception(entries_store, monkeypatch): + """If an UPDATE raises partway, the earlier UPDATEs must be rolled back -- + otherwise they sit pending on the shared connection and the next unrelated + commit() flushes a half-applied reorder.""" + a = await entries_store.add_entry( + list_id="lst-1", project_id="prj-1", text="A", original_text="A", + author_kind="agent", author_id="agent-1", + ) + b = await entries_store.add_entry( + list_id="lst-1", project_id="prj-1", text="B", original_text="B", + author_kind="agent", author_id="agent-1", + ) + + real_execute = entries_store._db.execute + update_calls = 0 + + async def failing_execute(sql, params=()): + nonlocal update_calls + if sql.startswith("UPDATE project_list_entries SET position"): + update_calls += 1 + if update_calls == 2: + raise RuntimeError("boom") + return await real_execute(sql, params) + + monkeypatch.setattr(entries_store._db, "execute", failing_execute) + + with pytest.raises(RuntimeError): + await entries_store.reorder_entries( + "prj-1", "lst-1", + [{"id": a["id"], "position": 1}, {"id": b["id"], "position": 0}], + ) + monkeypatch.undo() + + # The half-applied first UPDATE must be gone immediately... + a_after = await entries_store.get_entry(a["id"]) + b_after = await entries_store.get_entry(b["id"]) + assert a_after["position"] == 0 + assert b_after["position"] == 1 + + # ...and must NOT resurface when an unrelated write commits later. + await entries_store.add_entry( + list_id="lst-2", project_id="prj-1", text="Unrelated", + original_text="Unrelated", author_kind="agent", author_id="agent-1", + ) + a_final = await entries_store.get_entry(a["id"]) + assert a_final["position"] == 0 diff --git a/tinyagentos/projects/lists_store.py b/tinyagentos/projects/lists_store.py index be3695531..ac2face06 100644 --- a/tinyagentos/projects/lists_store.py +++ b/tinyagentos/projects/lists_store.py @@ -174,8 +174,9 @@ async def get_entry(self, entry_id: str) -> dict | None: row = await cur.fetchone() if row is None: return None - keys = [d[0] for d in cur.description] - return dict(zip(keys, row)) + # cur.description is only guaranteed while the cursor is open. + keys = [d[0] for d in cur.description] + return dict(zip(keys, row)) async def list_entries( self, @@ -259,15 +260,22 @@ async def delete_entry(self, entry_id: str) -> bool: return cursor.rowcount == 1 async def reorder_entries(self, project_id: str, list_id: str, entries: list[dict]) -> bool: - for entry in entries: - cursor = await self._db.execute( - "UPDATE project_list_entries SET position = ?, updated_at = ? " - "WHERE id = ? AND project_id = ? AND list_id = ?", - (entry["position"], time.time(), entry["id"], project_id, list_id), - ) - if cursor.rowcount == 0: - await self._db.rollback() - return False + try: + for entry in entries: + cursor = await self._db.execute( + "UPDATE project_list_entries SET position = ?, updated_at = ? " + "WHERE id = ? AND project_id = ? AND list_id = ?", + (entry["position"], time.time(), entry["id"], project_id, list_id), + ) + if cursor.rowcount == 0: + await self._db.rollback() + return False + except Exception: + # Without this, the UPDATEs already issued stay pending on the + # shared connection and the next unrelated commit() flushes a + # half-applied reorder. Roll back, then re-raise. + await self._db.rollback() + raise await self._db.commit() return True From 2fee9379ad29c2bd2ba2ede5360c7d97f931dcb7 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Tue, 11 Aug 2026 20:31:48 +0000 Subject: [PATCH 53/56] fix(lists): widen reorder rollback guard to BaseException, cover commit() CodeRabbit's Major on #2361, folded: asyncio.CancelledError does not inherit Exception, so task cancellation mid-reorder left the issued UPDATEs pending exactly like the original hazard, and commit() sat outside the guard. The commit moves inside the try and the handler catches BaseException, rolling back before re-raising. Regression tests for both paths; the cancellation test proven red against the except-Exception guard. --- tests/projects/test_lists_store.py | 71 +++++++++++++++++++++++++++++ tinyagentos/projects/lists_store.py | 8 ++-- 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/tests/projects/test_lists_store.py b/tests/projects/test_lists_store.py index 0c186063c..5252bdc0b 100644 --- a/tests/projects/test_lists_store.py +++ b/tests/projects/test_lists_store.py @@ -395,3 +395,74 @@ async def failing_execute(sql, params=()): ) a_final = await entries_store.get_entry(a["id"]) assert a_final["position"] == 0 + + +@pytest.mark.asyncio +async def test_reorder_entries_rolls_back_on_cancellation(entries_store, monkeypatch): + """CancelledError is not an Exception; the rollback guard must still fire.""" + a = await entries_store.add_entry( + list_id="lst-1", project_id="prj-1", text="A", original_text="A", + author_kind="agent", author_id="agent-1", + ) + b = await entries_store.add_entry( + list_id="lst-1", project_id="prj-1", text="B", original_text="B", + author_kind="agent", author_id="agent-1", + ) + + real_execute = entries_store._db.execute + update_calls = 0 + + async def cancelling_execute(sql, params=()): + nonlocal update_calls + if sql.startswith("UPDATE project_list_entries SET position"): + update_calls += 1 + if update_calls == 2: + raise asyncio.CancelledError() + return await real_execute(sql, params) + + monkeypatch.setattr(entries_store._db, "execute", cancelling_execute) + + with pytest.raises(asyncio.CancelledError): + await entries_store.reorder_entries( + "prj-1", "lst-1", + [{"id": a["id"], "position": 1}, {"id": b["id"], "position": 0}], + ) + monkeypatch.undo() + + await entries_store.add_entry( + list_id="lst-2", project_id="prj-1", text="Unrelated", + original_text="Unrelated", author_kind="agent", author_id="agent-1", + ) + assert (await entries_store.get_entry(a["id"]))["position"] == 0 + assert (await entries_store.get_entry(b["id"]))["position"] == 1 + + +@pytest.mark.asyncio +async def test_reorder_entries_rolls_back_on_commit_failure(entries_store, monkeypatch): + a = await entries_store.add_entry( + list_id="lst-1", project_id="prj-1", text="A", original_text="A", + author_kind="agent", author_id="agent-1", + ) + b = await entries_store.add_entry( + list_id="lst-1", project_id="prj-1", text="B", original_text="B", + author_kind="agent", author_id="agent-1", + ) + + async def failing_commit(): + raise RuntimeError("commit boom") + + monkeypatch.setattr(entries_store._db, "commit", failing_commit) + + with pytest.raises(RuntimeError): + await entries_store.reorder_entries( + "prj-1", "lst-1", + [{"id": a["id"], "position": 1}, {"id": b["id"], "position": 0}], + ) + monkeypatch.undo() + + await entries_store.add_entry( + list_id="lst-2", project_id="prj-1", text="Unrelated", + original_text="Unrelated", author_kind="agent", author_id="agent-1", + ) + assert (await entries_store.get_entry(a["id"]))["position"] == 0 + assert (await entries_store.get_entry(b["id"]))["position"] == 1 diff --git a/tinyagentos/projects/lists_store.py b/tinyagentos/projects/lists_store.py index ac2face06..81aa60f4a 100644 --- a/tinyagentos/projects/lists_store.py +++ b/tinyagentos/projects/lists_store.py @@ -270,13 +270,15 @@ async def reorder_entries(self, project_id: str, list_id: str, entries: list[dic if cursor.rowcount == 0: await self._db.rollback() return False - except Exception: + await self._db.commit() + except BaseException: # Without this, the UPDATEs already issued stay pending on the # shared connection and the next unrelated commit() flushes a - # half-applied reorder. Roll back, then re-raise. + # half-applied reorder. BaseException, not Exception: task + # cancellation (CancelledError) must also roll back, and commit() + # itself is inside the guard for the same reason. await self._db.rollback() raise - await self._db.commit() return True async def _get_next_position(self, project_id: str, list_id: str) -> int: From 7f15e1d57c9ac35ba32ffbd66b956ed8a7c3a699 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Tue, 11 Aug 2026 20:55:31 +0000 Subject: [PATCH 54/56] fix(auth): let consent-key clients reach the /v1 agent-model surface (tsk-hfs6zv) routes/agent_model_api.py enforces its own consent-key auth (never resolves a model without a valid key, OpenAI-shaped 401 otherwise) but the middleware had no passthrough for /v1 paths, so every external OpenAI-compatible caller was rejected by the session gate before the handler ran - the surface was dead code from outside (found by hermes's live probe, bus 2380). Exempts exactly GET /v1/models and POST /v1/chat/completions, method-sensitive; /v1/anything-else and wrong-method requests stay session-gated, with tests for both directions. External-caller tests (no session cookie) proven red against the unmodified middleware, including a minted-key 200 end to end. The chat route's 501-until-seam behavior is untouched and now documented in agent-coordination.md. --- .../2363-v1-consent-key-passthrough.md | 7 ++ docs/agent-coordination.md | 12 ++- tests/test_routes_agent_model_api.py | 77 +++++++++++++++++++ tinyagentos/auth_middleware.py | 15 ++++ 4 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 changelog.d/2363-v1-consent-key-passthrough.md diff --git a/changelog.d/2363-v1-consent-key-passthrough.md b/changelog.d/2363-v1-consent-key-passthrough.md new file mode 100644 index 000000000..a78d184d0 --- /dev/null +++ b/changelog.d/2363-v1-consent-key-passthrough.md @@ -0,0 +1,7 @@ +### Fixed + +- The Agent-as-a-Model surface (`GET /v1/models`, `POST /v1/chat/completions`) + is now reachable by external OpenAI-compatible clients: the auth middleware + passes exactly those two routes through to their own consent-key check + instead of rejecting every session-less caller before the handler ran. All + other `/v1` paths remain session-gated (tsk-hfs6zv). diff --git a/docs/agent-coordination.md b/docs/agent-coordination.md index a01af46ec..d7d6b61c6 100644 --- a/docs/agent-coordination.md +++ b/docs/agent-coordination.md @@ -222,7 +222,17 @@ the proxy returns 502 (the read proxies degrade to an empty 200 instead). A registered external agent authenticates with its registry JWT (`Authorization: Bearer`) and reaches exactly the routes its granted SCOPES allow, nothing else: the middleware allowlist is a closed set, no skeleton key. -The surface, by scope: + +A SEPARATE credential class exists for the Agent-as-a-Model surface: +`GET /v1/models` and `POST /v1/chat/completions` are reachable without a +session using a CONSENT KEY (`Authorization: Bearer sk-taosagent-...`, minted +by an owner via `/api/agent-model-keys`), which the route itself validates — +no key, no resolution, OpenAI-shaped 401 otherwise. Only those two exact +method+path pairs pass the middleware; any other `/v1` path stays +session-gated. `POST /v1/chat/completions` returns 501 for a valid key until +the opencode host-server turn seam lands (decided 2026-06-23, unbuilt). + +The registry-JWT surface, by scope: - **project_tasks** (the kanban board): `GET /api/projects/{pid}/tasks`, `.../tasks/ready`, `.../tasks/{id}`, `.../tasks/{id}/comments` (GET + POST), diff --git a/tests/test_routes_agent_model_api.py b/tests/test_routes_agent_model_api.py index b7847f7a6..d5a60c05c 100644 --- a/tests/test_routes_agent_model_api.py +++ b/tests/test_routes_agent_model_api.py @@ -145,3 +145,80 @@ async def test_chat_missing_model_is_openai_shaped_400(client): assert resp.status_code == 400 # OpenAI envelope, not FastAPI's default {"detail": [...]} 422. assert resp.json()["error"]["type"] == "invalid_request_error" + + +# --------------------------------------------------------------------------- +# Middleware passthrough (tsk-hfs6zv): an EXTERNAL client (no session cookie) +# must reach the /v1 handlers, whose consent-key check is the credential. +# Before the exemption, the session gate 401d these requests before the +# handler ran, so the surface was unreachable from outside by design-gap. +# --------------------------------------------------------------------------- + +from httpx import ASGITransport, AsyncClient + + +@pytest.fixture +def bare_client(client): + """Session-less client against the same app: what an external + OpenAI-compatible caller looks like.""" + app = client._transport.app + return AsyncClient(transport=ASGITransport(app=app), base_url="http://test") + + +@pytest.mark.asyncio +async def test_external_models_reaches_route_auth(bare_client): + """No session, no key: the 401 must be the ROUTE's OpenAI envelope, + not the middleware's generic 401.""" + async with bare_client as c: + resp = await c.get("/v1/models") + assert resp.status_code == 401 + assert resp.json()["error"]["code"] == "invalid_api_key" + + +@pytest.mark.asyncio +async def test_external_chat_completions_reaches_route_auth(bare_client): + async with bare_client as c: + resp = await c.post( + "/v1/chat/completions", + headers={"Authorization": "Bearer sk-taosagent-bogus"}, + json={"model": "agent-a", "messages": [{"role": "user", "content": "hi"}]}, + ) + assert resp.status_code == 401 + assert resp.json()["error"]["code"] == "invalid_api_key" + + +@pytest.mark.asyncio +async def test_external_valid_key_lists_models_end_to_end(client, bare_client): + """A minted consent key authenticates an external caller through the + middleware all the way to a 200 model list.""" + store = client._transport.app.state.agent_model_keys + token, _ = await store.mint("u1", ["agent-a"], ["memory_read"]) + async with bare_client as c: + resp = await c.get("/v1/models", headers={"Authorization": f"Bearer {token}"}) + assert resp.status_code == 200, resp.text + assert [m["id"] for m in resp.json()["data"]] == ["agent-a"] + + +@pytest.mark.asyncio +async def test_unlisted_v1_path_stays_session_gated(bare_client): + """The exemption is exact-path: any other /v1 path must still hit the + session gate, not fall through as a public 404.""" + async with bare_client as c: + resp = await c.get("/v1/other") + assert resp.status_code == 401 + # The middleware's generic 401 ({"error": "Authentication required"}), + # never the route's OpenAI envelope ({"error": {"code": ...}}). + assert not isinstance(resp.json().get("error"), dict) + + +@pytest.mark.asyncio +async def test_wrong_method_on_exempt_path_stays_gated(bare_client): + """Method-sensitivity: POST /v1/models and GET /v1/chat/completions are + not exempt.""" + async with bare_client as c: + r1 = await c.post("/v1/models") + r2 = await c.get("/v1/chat/completions") + assert r1.status_code == 401 + assert r2.status_code == 401 + for r in (r1, r2): + assert not isinstance(r.json().get("error"), dict) diff --git a/tinyagentos/auth_middleware.py b/tinyagentos/auth_middleware.py index 778bb73a5..10bf9a4e5 100644 --- a/tinyagentos/auth_middleware.py +++ b/tinyagentos/auth_middleware.py @@ -287,6 +287,15 @@ def _is_agent_scope_request_path(method: str, path: str) -> bool: _INVITE_REDEEM = "/api/projects/invites/redeem" _INVITE_INFO_PREFIX = "/i/" +# Agent-model OpenAI-compatible surface (routes/agent_model_api.py): the +# consent key IS the credential and the ROUTE enforces it — it never resolves +# a model without a valid key and answers 401 in an OpenAI error envelope +# otherwise (proven by tests/test_routes_agent_model_api.py). The middleware +# exempts EXACTLY the two mounted routes, method-sensitive; every other /v1 +# path stays session-gated so the exemption cannot become a skeleton key. +_AGENT_MODEL_MODELS = "/v1/models" +_AGENT_MODEL_CHAT = "/v1/chat/completions" + # Local-only shutdown drain: the systemd ExecStop hook (taos-graceful-stop) # POSTs this from localhost with no session cookie and no token, so it was # getting 401 and the in-app drain never ran. We exempt it ONLY for loopback @@ -406,6 +415,12 @@ def _is_exempt(method: str, path: str) -> bool: # logged-out admin is not exposed, but the invite id form is exempt. if method == "GET" and path.startswith(_INVITE_INFO_PREFIX) and "/" not in path[len(_INVITE_INFO_PREFIX):]: return True + # Agent-model surface — consent-key auth lives in the route (see the + # constants block above). Exact paths only; /v1/anything-else stays gated. + if method == "GET" and path == _AGENT_MODEL_MODELS: + return True + if method == "POST" and path == _AGENT_MODEL_CHAT: + return True return False From d07f5c3b095f2d56697cfdaaf7bc37348b577770 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Tue, 11 Aug 2026 22:39:04 +0100 Subject: [PATCH 55/56] VERIFY at next release: secret-material .gitignore is dev-ONLY, master has NO protection (#2362) * chore(security): gate .gitignore secret ignores on master/dev/release/* Add scripts/check_secret_ignores.py, which asserts that the committed .gitignore (a) still contains every required secret-protection rule as an active line and (b) ignores a canonical set of secret-shaped paths (data/hub/identity.json, foo.key, creds.json, x.p8, y_credentials.json, ...) via git check-ignore. Covered by tests/test_check_secret_ignores.py, including a parametrized test that drops each required pattern from a copy of the real .gitignore and proves the guard goes red, plus a real-tree regression asserting this branch is green. .github/workflows/secret-ignores-gate.yml runs the guard on push and PR to master, dev, and release/*, so a dropped pattern fails the branch it lands on rather than being assumed during dev->master promotion. Docs: Secret-ignores gate section in the contributor skill and a post-promotion verification step in docs/RELEASING.md. Changelog fragment added. Refs tsk-laezfg. * fix(security): ignore data/.litellm_master_key and add it to the gate canon The LiteLLM proxy master key has a bare _key suffix, so neither *.key nor data/*.key matches it: the file has been live, untracked, and one git add -A away from staging on dev boxes (long-standing queue item). This PR is the reviewed route for exactly that class of rule, so the rule lands here with both gate signals covering it; removal proven red: SECRET-IGNORE FAIL: .gitignore is missing required protection patterns: - data/.litellm_master_key * docs(releasing): tag the check_secret_ignores fence as bash (MD040) --- .../skills/taos-development-skill/SKILL.md | 23 ++ .github/workflows/secret-ignores-gate.yml | 35 +++ .gitignore | 1 + changelog.d/tsk-laezfg-secret-ignores-gate.md | 12 + docs/RELEASING.md | 14 ++ scripts/check_secret_ignores.py | 216 ++++++++++++++++++ tests/test_check_secret_ignores.py | 171 ++++++++++++++ 7 files changed, 472 insertions(+) create mode 100644 .github/workflows/secret-ignores-gate.yml create mode 100644 changelog.d/tsk-laezfg-secret-ignores-gate.md create mode 100644 scripts/check_secret_ignores.py create mode 100644 tests/test_check_secret_ignores.py diff --git a/.claude/skills/taos-development-skill/SKILL.md b/.claude/skills/taos-development-skill/SKILL.md index 5ce8e6859..08f9620d8 100644 --- a/.claude/skills/taos-development-skill/SKILL.md +++ b/.claude/skills/taos-development-skill/SKILL.md @@ -452,6 +452,29 @@ trailer, which is logged by the gate: Store-Unwired-Intentionally: , ``` +## Secret-ignores gate + +A gate (`.github/workflows/secret-ignores-gate.yml`, running `scripts/check_secret_ignores.py`) +verifies that the committed `.gitignore` still protects known secret-shaped paths on every +promotion target. A `.gitignore` rule is the kind of file a rebase conflict can quietly drop +during a dev->master promotion while every test stays green and nothing builds red, so the +protection is asserted here, not assumed. The gate runs on push to `master`, `dev` and +`release/*` (a dropped rule fails the branch it lands on) and on PRs to those branches (a +conflict-resolution loss fails before the merge, since the merge commit's `.gitignore` is what +is checked). + +Two signals, defense in depth: + +- Every required protection rule must appear verbatim as an active line of `.gitignore` + (`*.key`, `identity.json`, `*.p8`, `*credentials.json`, `*creds*.json`, the `*_private.*` + key shapes, `secrets/`, `data/hub/`, and the rest listed in `REQUIRED_PATTERNS` in the + script). Comment prose and narrower sibling rules do not satisfy a rule. +- A set of secret-shaped paths (`data/hub/identity.json`, `foo.key`, `creds.json`, `x.p8`, + `y_credentials.json`, ...) must all be reported ignored by `git check-ignore`. + +Removing any one protection pattern turns the gate red -- proven by a parametrized test that +drops each pattern from a copy of the real `.gitignore` and asserts the guard fails. + ## Upstream conventions (from CONTRIBUTING.md) - **Target branch is `dev`, not `master`.** `master` is the stable live-install track. diff --git a/.github/workflows/secret-ignores-gate.yml b/.github/workflows/secret-ignores-gate.yml new file mode 100644 index 000000000..20034fcf3 --- /dev/null +++ b/.github/workflows/secret-ignores-gate.yml @@ -0,0 +1,35 @@ +name: Secret-ignores gate + +# Verifies that the committed .gitignore still protects known secret-shaped +# paths (data/hub/identity.json, foo.key, creds.json, x.p8, ...) on every +# promotion target. A .gitignore is the kind of file a rebase conflict can +# quietly drop during a dev->master promotion while every test still passes and +# nothing builds red, so promotion is verified here, not assumed. +# +# Trigger scope is deliberate: +# - push to master/dev/release/* : a dropped pattern fails the branch it +# lands on (this is the post-promotion check from tsk-laezfg step 1). +# - pull_request to master/dev/release/* : a conflict-resolution loss fails +# BEFORE the merge, since the merge commit's .gitignore is what is checked. +# The run is pure stdlib (~1s), so it carries no shard-timeout risk. +# +# See scripts/check_secret_ignores.py for REQUIRED_PATTERNS and SECRET_PATHS. + +on: + push: + branches: [master, dev, release/*] + pull_request: + branches: [master, dev, release/*] + +jobs: + secret-ignores-gate: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 + with: + python-version: "3.12" + - name: Assert secret-shaped paths are ignored + run: python scripts/check_secret_ignores.py diff --git a/.gitignore b/.gitignore index 1f6dcc81f..63ce16c08 100644 --- a/.gitignore +++ b/.gitignore @@ -152,6 +152,7 @@ data/.seeded-agent-tokens.json data/secrets.db* data/*.key data/*.token +data/.litellm_master_key # Key material and secrets. Added 2026-07-27 after data/hub/identity.json was # committed to PR #2043 with signing_private and encryption_private in diff --git a/changelog.d/tsk-laezfg-secret-ignores-gate.md b/changelog.d/tsk-laezfg-secret-ignores-gate.md new file mode 100644 index 000000000..a65337acb --- /dev/null +++ b/changelog.d/tsk-laezfg-secret-ignores-gate.md @@ -0,0 +1,12 @@ +### Security + +- **CI**: `secret-ignores-gate` workflow and `scripts/check_secret_ignores.py` now + assert, on push to `master`/`dev`/`release/*` and on PRs to those branches, that the + committed `.gitignore` still contains every secret-protection rule (`*.key`, + `*.p8`, `identity.json`, `*credentials.json`, `*creds*.json`, the `*_private.*` key + shapes, `secrets/`, `data/hub/`, and more) and that known secret-shaped paths + (`data/hub/identity.json`, `foo.key`, `creds.json`, `x.p8`, `y_credentials.json`, + ...) are all reported ignored by `git check-ignore`. Closes the "promotion must be + verified, not assumed" gap from #2171/#2173: a `.gitignore` conflict resolution can + quietly drop a key-material rule while every test stays green. Removing any one + pattern is proven to fail the gate by a parametrized test. diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 5c0e4419f..d35eeefdc 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -34,6 +34,20 @@ CI runs the backend pytest suite and frontend vitest on every PR; both must be g Once the PR is merged to `dev`, open a follow-up PR from `dev` to `master`. After that PR merges, the install-count telemetry at taos.my starts recording the new version for every fresh install. +The `secret-ignores-gate` runs on the `master` push (and on the PR merge result) +and confirms the promoted `.gitignore` still ignores every secret-shaped path it +did on `dev` -- `identity.json`, `*.key`, `*.p8`, `*credentials.json`, `*creds*.json` +and the `*_private.*` key shapes, plus the `secrets/` and `data/hub/` rules. Re-run +it by hand if a conflict resolution touched `.gitignore`: + +```bash +python3 scripts/check_secret_ignores.py +``` + +Do not skip this: a `.gitignore` conflict resolution can quietly drop a +key-material rule while every test stays green. The gate is the verification, not +an assumption. + ### 5. Tag and create a GitHub Release On `master`, after the merge commit: diff --git a/scripts/check_secret_ignores.py b/scripts/check_secret_ignores.py new file mode 100644 index 000000000..8a86819f5 --- /dev/null +++ b/scripts/check_secret_ignores.py @@ -0,0 +1,216 @@ +#!/usr/bin/env python3 +"""Secret-material ignore guard. + +Verifies that the committed `.gitignore` on the branch under test still +protects known secret-shaped paths. After a dev->master promotion a +`.gitignore` rule can be silently dropped during conflict resolution (a +`.gitignore` is exactly the kind of file a rebase conflict quietly loses while +every test still passes and nothing builds red), so this gate asserts the +protection mechanically instead of assuming promotion carried it. + +The check runs on push to `master`, `dev` and `release/*` -- so a dropped +pattern fails the branch it actually lands on -- and on PRs targeting those +branches -- so a conflict-resolution loss fails BEFORE the merge. See +`.github/workflows/secret-ignores-gate.yml`. + +Two independent signals, defense in depth: + + 1. REQUIRED_PATTERNS. Each entry must appear verbatim as an active rule line + in `.gitignore`. A rule line is a non-blank, non-comment line (comment = + first non-whitespace char is `#`). Matching the exact line (rather than a + loose substring across the file) is deliberate: it ignores the comment + prose that discusses these patterns and treats a different-but-similar + rule (e.g. `data/*.key`) as NOT satisfying the `master`-level `*.key` rule. + Removing any required rule line turns the gate red deterministically -- this + is the "prove it goes red by removing one pattern" mechanism. + + 2. SECRET_PATHS. Each path must be reported as ignored by `git check-ignore`, + i.e. the protection actually takes effect. This catches holes that line + presence cannot (a rule that is present but malformed, or a path that no + longer matches), and is the "secret-shaped paths are ALL ignored" check + from tsk-laezfg. + +Usage: + python scripts/check_secret_ignores.py + python scripts/check_secret_ignores.py --repo-root /path/to/repo +""" +from __future__ import annotations + +import argparse +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +GITIGNORE_NAME = ".gitignore" + +# Protection rules whose absence must fail the gate. These are the secret- +# material rules added in response to data/hub/identity.json being committed to +# PR #2043 with signing_private and encryption_private in plaintext (#2171, +# #2173). Each is matched as an exact active rule line so a dropped rule is +# detected even when its text is still mentioned in a comment or covered by a +# narrower sibling rule. +REQUIRED_PATTERNS = [ + "*.key", + "*_private.pem", + "*_private.key", + "*_private.json", + "*_private_key*", + "identity.json", + "*.p8", + "*credentials.json", + "*creds*.json", + "secrets/", + "*.token", + "*.cred", + "data/.secrets_key", + "data/.seeded-agent-tokens.json", + "data/secrets.db*", + "data/*.key", + "data/*.token", + "data/hub/", + # LiteLLM proxy master key: bare `_key` suffix, matched by NO glob above + # (data/*.key needs a `.key` suffix). It sat live and unignored on dev + # boxes until this rule (JAY-QUEUE item, 2026-08-08). + "data/.litellm_master_key", +] + +# Secret-shaped paths that must actually be ignored by `git check-ignore`. Each +# is annotated with its primary protecting rule. Not every path is exclusive +# (some are double-protected by design); the REQUIRED_PATTERNS check above is +# what makes every single pattern's removal detectable. These paths assert the +# protection has real teeth on the branch under test. +SECRET_PATHS = [ + "data/hub/identity.json", # data/hub/ , identity.json + "identity.json", # identity.json + "foo.key", # *.key + "x.p8", # *.p8 + "creds.json", # *creds*.json + "y_credentials.json", # *credentials.json (also *creds*.json) + "app_private.pem", # *_private.pem + "app_private.json", # *_private.json + "signing_private_key.pem", # *_private_key* + "foo.token", # *.token + "foo.cred", # *.cred + "secrets/foo.token", # secrets/ + "data/.secrets_key", # data/.secrets_key + "data/secrets.db-wal", # data/secrets.db* + "data/.litellm_master_key", # data/.litellm_master_key (exact) +] + + +@dataclass +class Violation: + kind: str # "pattern" or "path" + detail: str + + +def _run_git(args: list[str], repo_root: Path) -> subprocess.CompletedProcess: + return subprocess.run( + ["git", *args], + cwd=repo_root, + capture_output=True, + text=True, + ) + + +def _read_gitignore(repo_root: Path) -> str: + path = repo_root / GITIGNORE_NAME + if not path.exists(): + return "" + return path.read_text(encoding="utf-8") + + +def _active_rule_lines(gitignore_text: str) -> list[str]: + """Return the active (non-blank, non-comment) rule lines, stripped. + + A `.gitignore` rule line is an active rule unless it is blank or its first + non-whitespace character is `#` (a comment). Trailing/leading whitespace is + stripped so cosmetic reformatting does not make the gate trip. + """ + lines: list[str] = [] + for raw in gitignore_text.splitlines(): + stripped = raw.strip() + if not stripped or stripped.startswith("#"): + continue + lines.append(stripped) + return lines + + +def check_patterns(gitignore_text: str) -> list[str]: + """Return the required patterns absent from the .gitignore text.""" + active = _active_rule_lines(gitignore_text) + missing: list[str] = [] + for pattern in REQUIRED_PATTERNS: + if pattern not in active: + missing.append(pattern) + return missing + + +def is_path_ignored(path: str, repo_root: Path) -> bool: + """True if `git check-ignore` reports `path` as ignored on `repo_root`.""" + result = _run_git(["check-ignore", "--quiet", path], repo_root) + return result.returncode == 0 + + +def check_paths(repo_root: Path) -> list[str]: + """Return secret-shaped paths that are NOT ignored on `repo_root`.""" + not_ignored: list[str] = [] + for path in SECRET_PATHS: + if not is_path_ignored(path, repo_root): + not_ignored.append(path) + return not_ignored + + +def check_secret_ignores(repo_root: Path = REPO_ROOT) -> list[Violation]: + """Return all violations on `repo_root` (empty == clean).""" + text = _read_gitignore(repo_root) + violations: list[Violation] = [ + Violation("pattern", p) for p in check_patterns(text) + ] + # `git check-ignore` requires a usable git directory; a missing .git is + # treated as "nothing is ignored" so every secret path is a violation. + if not is_usable_git_repo(repo_root): + violations.extend(Violation("path", p) for p in SECRET_PATHS) + return violations + violations.extend(Violation("path", p) for p in check_paths(repo_root)) + return violations + + +def is_usable_git_repo(repo_root: Path) -> bool: + """True if `repo_root` is a git working tree we can check-ignore against.""" + result = _run_git(["rev-parse", "--is-inside-work-tree"], repo_root) + return result.returncode == 0 and result.stdout.strip() == "true" + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--repo-root", + default=str(REPO_ROOT), + help="Repository root whose .gitignore to check (default: this checkout).", + ) + args = parser.parse_args(argv) + repo_root = Path(args.repo_root) + + violations = check_secret_ignores(repo_root) + if not violations: + print("secret-ignores-guard: clean") + return 0 + + patterns = sorted(v.detail for v in violations if v.kind == "pattern") + paths = sorted(v.detail for v in violations if v.kind == "path") + if patterns: + print("SECRET-IGNORE FAIL: .gitignore is missing required protection patterns:") + for p in patterns: + print(f" - {p}") + if paths: + print("SECRET-IGNORE FAIL: these secret-shaped paths would NOT be ignored:") + for p in paths: + print(f" - {p}") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_check_secret_ignores.py b/tests/test_check_secret_ignores.py new file mode 100644 index 000000000..a9f539b32 --- /dev/null +++ b/tests/test_check_secret_ignores.py @@ -0,0 +1,171 @@ +"""Tests for the secret-material ignore guard (scripts/check_secret_ignores.py). + +The guard asserts two things on the branch under test: + 1. Every REQUIRED_PATTERN appears as an exact active rule line in .gitignore. + 2. Every SECRET_PATH is reported ignored by `git check-ignore`. + +The headline guarantee from tsk-laezfg is "PROVEN to fail when a pattern is +removed": a parametrized test builds a synthetic repo from a copy of the real +.gitignore, drops ONE required pattern line, and asserts the guard goes red. A +companion real-tree test asserts the committed .gitignore on this branch is +green, so the gate is a live regression guard and not dead code. +""" +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +import pytest + +# scripts/ is not a package; make it importable like the other scripts/*.py +# gate tests (see tests/test_check_deleted_symbols.py). +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts")) +import check_secret_ignores as csi # noqa: E402 + +REPO_ROOT = Path(__file__).resolve().parent.parent +REAL_GITIGNORE = REPO_ROOT / ".gitignore" + + +def _git(repo: Path, *args: str) -> None: + subprocess.run(["git", *args], cwd=repo, capture_output=True, text=True, check=True) + + +def _init_repo(repo: Path) -> None: + repo.mkdir(parents=True, exist_ok=True) + _git(repo, "init") + _git(repo, "config", "user.name", "Test") + _git(repo, "config", "user.email", "test@test.com") + _git(repo, "config", "commit.gpgsign", "false") + # No inherited global/system ignores -- the only ignores in the synthetic + # repo come from the .gitignore we write, so the gate's path assertions stay + # deterministic. + _git(repo, "config", "core.excludesFile", "/dev/null") + _git(repo, "branch", "-M", "main") + + +def _commit_gitignore(repo: Path, text: str) -> None: + (repo / ".gitignore").write_text(text, encoding="utf-8") + _git(repo, "add", ".gitignore") + _git(repo, "commit", "-m", "gitignore") + + +def _gitignore_without(text: str, pattern: str) -> str: + """Return `text` with the exact active rule line `pattern` removed.""" + kept: list[str] = [] + for raw in text.splitlines(): + if raw.strip() == pattern: + continue + kept.append(raw) + return "\n".join(kept) + ("\n" if text.endswith("\n") else "") + + +# --------------------------------------------------------------------------- +# Pattern-presence check (pure, no git) +# --------------------------------------------------------------------------- + + +class TestCheckPatterns: + def test_active_rule_lines_drops_comments_and_blanks(self): + text = ( + "# a comment *.key\n" + "\n" + " *.key \n" + "data/hub/\n" + ) + assert csi._active_rule_lines(text) == ["*.key", "data/hub/"] + + def test_all_required_patterns_present_on_real_tree(self): + text = REAL_GITIGNORE.read_text(encoding="utf-8") + assert csi.check_patterns(text) == [] + + @pytest.mark.parametrize("pattern", csi.REQUIRED_PATTERNS) + def test_removing_a_single_pattern_is_detected(self, pattern: str): + """Dropping ONE required rule line turns the pattern check red for + exactly that pattern -- this is the core 'remove a pattern -> red' + proof, run against every required pattern.""" + text = _gitignore_without(REAL_GITIGNORE.read_text(encoding="utf-8"), pattern) + missing = csi.check_patterns(text) + assert missing == [pattern] + + def test_comment_mention_does_not_satisfy_a_pattern(self): + """A pattern name in a comment (#-prefixed) must not count as present.""" + text = "# *.key is for key material\ndata/hub/\n" + assert "data/hub/" not in csi.check_patterns(text) + assert "*.key" in csi.check_patterns(text) + + def test_narrow_rule_does_not_satisfy_a_root_rule(self): + """`data/*.key` must not satisfy the master-level `*.key` rule, and vice + versa -- matching is exact-line, not substring.""" + assert "*.key" in csi.check_patterns("data/*.key\n") + assert "data/*.key" in csi.check_patterns("*.key\n") + + def test_missing_gitignore_reports_all_patterns(self): + assert set(csi.REQUIRED_PATTERNS).issubset(set(csi.check_patterns(""))) + + +# --------------------------------------------------------------------------- +# Path-check (git check-ignore), integration with synthetic repos +# --------------------------------------------------------------------------- + + +class TestCheckPaths: + def test_secret_paths_ignored_on_real_gitignore(self, tmp_path: Path): + repo = tmp_path / "repo" + _init_repo(repo) + _commit_gitignore(repo, REAL_GITIGNORE.read_text(encoding="utf-8")) + assert csi.check_paths(repo) == [] + + def test_path_not_ignored_when_pattern_removed(self, tmp_path: Path): + repo = tmp_path / "repo" + _init_repo(repo) + text = _gitignore_without( + REAL_GITIGNORE.read_text(encoding="utf-8"), "*.key" + ) + _commit_gitignore(repo, text) + assert "foo.key" in csi.check_paths(repo) + + def test_path_under_ignored_dir_is_ignored(self, tmp_path: Path): + repo = tmp_path / "repo" + _init_repo(repo) + _commit_gitignore(repo, "data/hub/\nidentity.json\n") + assert csi.is_path_ignored("data/hub/identity.json", repo) + + +# --------------------------------------------------------------------------- +# Full guard: green on the real tree, red on a single removed pattern +# --------------------------------------------------------------------------- + + +class TestCheckSecretIgnores: + def test_real_tree_passes(self): + assert csi.check_secret_ignores(REPO_ROOT) == [] + + @pytest.mark.parametrize("pattern", csi.REQUIRED_PATTERNS) + def test_real_gitignore_minus_one_pattern_fails(self, tmp_path: Path, pattern: str): + """PROVEN red: copy the committed .gitignore, drop ONE required pattern, + and the guard must report a violation.""" + repo = tmp_path / "repo" + _init_repo(repo) + text = _gitignore_without(REAL_GITIGNORE.read_text(encoding="utf-8"), pattern) + _commit_gitignore(repo, text) + + violations = csi.check_secret_ignores(repo) + + pattern_violations = [v for v in violations if v.kind == "pattern"] + assert pattern_violations, f"removing {pattern!r} did not trip the pattern guard" + assert any(v.detail == pattern for v in pattern_violations) + + def test_real_gitignore_missing_key_makes_foo_key_unignored( + self, tmp_path: Path + ): + repo = tmp_path / "repo" + _init_repo(repo) + text = _gitignore_without(REAL_GITIGNORE.read_text(encoding="utf-8"), "*.key") + _commit_gitignore(repo, text) + + violations = csi.check_secret_ignores(repo) + + details = {v.detail for v in violations} + assert "*.key" in details + assert "foo.key" in details From 58d035bb83fba45e1f3cb275ebeb093d2eb20def Mon Sep 17 00:00:00 2001 From: jaylfc Date: Tue, 11 Aug 2026 21:48:45 +0000 Subject: [PATCH 56/56] chore(release): v1.0.0-beta.48 version bump + changelog Collate 15 changelog.d fragments into the 1.0.0-beta.48 section (merged with the direct [Unreleased] entries under single headings), bump the four version files including uv.lock's normalized 1.0.0b48. Docs-Reviewed: release version bump; no CI, packaging or contribution rule change --- CHANGELOG.md | 113 ++++++++++++++++-- changelog.d/2333-strike-quarantine-wiring.md | 5 - changelog.d/2338-hailo-hef-catalog-fixes.md | 7 -- changelog.d/2349-alias-editing.md | 6 - changelog.d/2352-memory-agent-traversal.md | 6 - changelog.d/2353-ci-node-22.md | 4 - changelog.d/2356-registry-existence-hiding.md | 7 -- changelog.d/2357-wallpaper-fit-options.md | 6 - .../2361-lists-store-cursor-rollback.md | 6 - .../2363-v1-consent-key-passthrough.md | 7 -- changelog.d/fix-auth-status-ua-symmetry.md | 9 -- changelog.d/tsk-icpt4i-agent-loop-wiring.md | 12 -- changelog.d/tsk-laezfg-secret-ignores-gate.md | 12 -- changelog.d/tsk-n3w5mh-store-wiring-gate.md | 11 -- changelog.d/tsk-ppgpln-context-window.md | 8 -- changelog.d/tsk-rl2lfb-agent-loop.md | 6 - desktop/package.json | 2 +- pyproject.toml | 2 +- tinyagentos/__init__.py | 2 +- uv.lock | 2 +- 20 files changed, 110 insertions(+), 123 deletions(-) delete mode 100644 changelog.d/2333-strike-quarantine-wiring.md delete mode 100644 changelog.d/2338-hailo-hef-catalog-fixes.md delete mode 100644 changelog.d/2349-alias-editing.md delete mode 100644 changelog.d/2352-memory-agent-traversal.md delete mode 100644 changelog.d/2353-ci-node-22.md delete mode 100644 changelog.d/2356-registry-existence-hiding.md delete mode 100644 changelog.d/2357-wallpaper-fit-options.md delete mode 100644 changelog.d/2361-lists-store-cursor-rollback.md delete mode 100644 changelog.d/2363-v1-consent-key-passthrough.md delete mode 100644 changelog.d/fix-auth-status-ua-symmetry.md delete mode 100644 changelog.d/tsk-icpt4i-agent-loop-wiring.md delete mode 100644 changelog.d/tsk-laezfg-secret-ignores-gate.md delete mode 100644 changelog.d/tsk-n3w5mh-store-wiring-gate.md delete mode 100644 changelog.d/tsk-ppgpln-context-window.md delete mode 100644 changelog.d/tsk-rl2lfb-agent-loop.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 590a05a90..4fbfdd830 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,21 +7,120 @@ Versions follow semver beta: `1.0.0-beta.N`, bumped on each dev->master promotio ## [Unreleased] -### Security - -- **Desktop deps**: bump `dompurify` 3.4.12 -> 3.4.13 (GHSA-55q2-fjhq-7xh7, - moderate) and `nanoid` 5.1.11 -> 5.1.16 (CVE-2026-67214, high) in - `desktop/package-lock.json`; lock-only, both already within the declared - ranges. Split out of Dependabot #2331, whose grouped jsdom 30 bump fails - spa-build (jsdom 30 requires Node >=22.13; CI pins Node 20). +## [1.0.0-beta.48] - 2026-08-11 ### Added +- Quarantined task cards surface their strike count and latest strike on the + task-detail response, and a lead can un-quarantine a card via + `POST /api/projects/{pid}/tasks/{tid}/unquarantine`, clearing its strikes (#2333). + +- **Hailo-10H HEF model catalog**: five NPU-accelerated model manifests + (DeepSeek-R1-Distill-Qwen 1.5B, Llama 3.2 3B, Qwen2 1.5B, Qwen2.5 1.5B, + Qwen2.5 Coder 1.5B) now resolve and install via `hailo-ollama` on + Raspberry Pi 5 + AI HAT+2, and downloaded `.hef` files show up in the + local-files and orphan scans (#2338). + +- The Agents app registry panel shows each agent's handle (alias) and lets the + owner or an admin edit it inline, saved via + `PATCH /api/agents/registry/{canonical_id}`. A leading `@` is display syntax + and is stripped before save (#2349). + +- Wallpaper fit options in Settings -> Desktop & Dock: fill, fit, stretch, + center, and tile. The choice is persisted per device (localStorage, keyed + by a locally minted device id that is never sent to the server), so each + screen keeps the fit that suits its aspect ratio (#2357). + +- **Agent loop infrastructure**: new `tinyagentos.agent_loop.AgentLoop` library + for subagent delegation and safe-point message queuing. Landed as + standalone infrastructure; wired into the chat router and taOS agent + routes in #tsk-icpt4i (#tsk-rl2lfb). + +- **CI**: store-wiring-gate workflow and `scripts/check_store_wiring.py` guard. + A PR that adds a new BaseStore subclass without wiring it into + `tinyagentos/app.py` now fails CI and names the unreachable class and file. + Routes reach stores ONLY via `request.app.state`, so an unwired store is + dead code. Only newly added classes are policed; a + `Store-Unwired-Intentionally: , ` trailer in the PR body + waives a named class for stores genuinely constructed elsewhere (tsk-n3w5mh). + - **Docs**: mechanical-simple-auditable design law added to the agent manual (`01-rules.md`), with a worked example anonymised as "an agent". Also trimmed verbose prose in the image-prompting guide to stay within the compiled manual character budget. +### Changed + +- CI's `spa-build` job runs on Node 22 (was 20, now past end-of-life). Also + unblocks the jsdom 30 upgrade, which requires Node >= 22.13 (#2353). + +- **Agent loop wiring**: `AgentLoop` is now the single per-agent serialization + owner. `AgentChatRouter` drives OpenClaw ACP turns through a per-agent + `AgentLoop` (replacing the per-agent lock) and the turn-holder drives + messages queued mid-turn at its safe point. The desktop taOS agent chat + endpoint serializes on one `AgentLoop` too -- fixing a race where two + concurrent POSTs shared the opencode session with no serialization -- + queueing concurrent messages and surfacing them in the turn-holder's stream + tail. New `GET /api/taos-agent/status` endpoint returns the desktop loop's + status scoped to state / current turn / queue depth / subagent descriptors + (subagent result/error payloads stay server-side) (#tsk-icpt4i). + +### Fixed + +- Project list entries: `get_entry` no longer reads cursor metadata after the + cursor closes, and a failed reorder now rolls back its partial updates so a + later unrelated write cannot commit a half-applied ordering (tsk-u23vjy, + fix-forward of #2183). + +- The Agent-as-a-Model surface (`GET /v1/models`, `POST /v1/chat/completions`) + is now reachable by external OpenAI-compatible clients: the auth middleware + passes exactly those two routes through to their own consent-key check + instead of rejecting every session-less caller before the handler ran. All + other `/v1` paths remain session-gated (tsk-hfs6zv). + +- **PWA refresh loop after a browser auto-update**: `/auth/status`, `/auth/me` + and the chat/canvas/terminal/web-chat WebSocket handlers now apply the same + session User-Agent binding check as the API middleware. Previously a session + created before a browser update kept reading as authenticated on + `/auth/status` while every `/api/*` call was rejected, so the desktop shell + remounted in a loop; the WebSocket endpoints conversely accepted a cookie + the APIs refused. + +- **Catalog manifests' `context_window` was silently dropped**: `AppManifest` + declared no `context_window` field and `from_dict` never read the YAML + value, so every manifest loaded as 0 and the chat context-window budget code + always fell back to the 4000-token "unknown window" default. The field now + loads onto `AppManifest` (0 reserved for unknown), so real windows -- e.g. + rkllm 4096, qwen 32768 -- drive the #1740 budget math. (#2338, #1740) + +### Security + +- Memory routes reject any `agent` value that is not a single plain path + component (separators, `.`/`..`, NUL all 400): the caller-controlled name + becomes a filesystem path component of the qmd `dbPath`, and a traversal + value could previously address SQLite files outside `agent-memory/` (#2352). + +- All owner-gated agent-registry routes are now existence-hiding: a caller who + does not own an agent gets the same 404 as a nonexistent id, on the + scope-request create/approve/deny routes and on registry PATCH, revoke, + rotate-tokens, and org update. Previously a 403-vs-404 difference disclosed + whether an agent id existed (issue #2106, reported by hognek) (#2356). + +- **CI**: `secret-ignores-gate` workflow and `scripts/check_secret_ignores.py` now + assert, on push to `master`/`dev`/`release/*` and on PRs to those branches, that the + committed `.gitignore` still contains every secret-protection rule (`*.key`, + `*.p8`, `identity.json`, `*credentials.json`, `*creds*.json`, the `*_private.*` key + shapes, `secrets/`, `data/hub/`, and more) and that known secret-shaped paths + are all reported ignored by `git check-ignore`. Closes the "promotion must be + verified, not assumed" gap from #2171/#2173. Removing any one pattern is + proven to fail the gate by a parametrized test (tsk-laezfg). + +- **Desktop deps**: bump `dompurify` 3.4.12 -> 3.4.13 (GHSA-55q2-fjhq-7xh7, + moderate) and `nanoid` 5.1.11 -> 5.1.16 (CVE-2026-67214, high) in + `desktop/package-lock.json`; lock-only, both already within the declared + ranges. Split out of Dependabot #2331, whose grouped jsdom 30 bump fails + spa-build (jsdom 30 requires Node >=22.13; CI pins Node 20). + ## [1.0.0-beta.47] - 2026-08-09 ### Added diff --git a/changelog.d/2333-strike-quarantine-wiring.md b/changelog.d/2333-strike-quarantine-wiring.md deleted file mode 100644 index 43e1e759a..000000000 --- a/changelog.d/2333-strike-quarantine-wiring.md +++ /dev/null @@ -1,5 +0,0 @@ -### Added - -- Quarantined task cards surface their strike count and latest strike on the - task-detail response, and a lead can un-quarantine a card via - `POST /api/projects/{pid}/tasks/{tid}/unquarantine`, clearing its strikes (#2333). diff --git a/changelog.d/2338-hailo-hef-catalog-fixes.md b/changelog.d/2338-hailo-hef-catalog-fixes.md deleted file mode 100644 index d2c1e20c2..000000000 --- a/changelog.d/2338-hailo-hef-catalog-fixes.md +++ /dev/null @@ -1,7 +0,0 @@ -### Added - -- **Hailo-10H HEF model catalog**: five NPU-accelerated model manifests - (DeepSeek-R1-Distill-Qwen 1.5B, Llama 3.2 3B, Qwen2 1.5B, Qwen2.5 1.5B, - Qwen2.5 Coder 1.5B) now resolve and install via `hailo-ollama` on - Raspberry Pi 5 + AI HAT+2, and downloaded `.hef` files show up in the - local-files and orphan scans (#2338). diff --git a/changelog.d/2349-alias-editing.md b/changelog.d/2349-alias-editing.md deleted file mode 100644 index ba503c7c0..000000000 --- a/changelog.d/2349-alias-editing.md +++ /dev/null @@ -1,6 +0,0 @@ -### Added - -- The Agents app registry panel shows each agent's handle (alias) and lets the - owner or an admin edit it inline, saved via - `PATCH /api/agents/registry/{canonical_id}`. A leading `@` is display syntax - and is stripped before save (#2349). diff --git a/changelog.d/2352-memory-agent-traversal.md b/changelog.d/2352-memory-agent-traversal.md deleted file mode 100644 index a00b3385a..000000000 --- a/changelog.d/2352-memory-agent-traversal.md +++ /dev/null @@ -1,6 +0,0 @@ -### Security - -- Memory routes reject any `agent` value that is not a single plain path - component (separators, `.`/`..`, NUL all 400): the caller-controlled name - becomes a filesystem path component of the qmd `dbPath`, and a traversal - value could previously address SQLite files outside `agent-memory/` (#2352). diff --git a/changelog.d/2353-ci-node-22.md b/changelog.d/2353-ci-node-22.md deleted file mode 100644 index 618584cf8..000000000 --- a/changelog.d/2353-ci-node-22.md +++ /dev/null @@ -1,4 +0,0 @@ -### Changed - -- CI's `spa-build` job runs on Node 22 (was 20, now past end-of-life). Also - unblocks the jsdom 30 upgrade, which requires Node >= 22.13 (#2353). diff --git a/changelog.d/2356-registry-existence-hiding.md b/changelog.d/2356-registry-existence-hiding.md deleted file mode 100644 index 7ce8f5f25..000000000 --- a/changelog.d/2356-registry-existence-hiding.md +++ /dev/null @@ -1,7 +0,0 @@ -### Security - -- All owner-gated agent-registry routes are now existence-hiding: a caller who - does not own an agent gets the same 404 as a nonexistent id, on the - scope-request create/approve/deny routes and on registry PATCH, revoke, - rotate-tokens, and org update. Previously a 403-vs-404 difference disclosed - whether an agent id existed (issue #2106, reported by hognek) (#2356). diff --git a/changelog.d/2357-wallpaper-fit-options.md b/changelog.d/2357-wallpaper-fit-options.md deleted file mode 100644 index bd50e3030..000000000 --- a/changelog.d/2357-wallpaper-fit-options.md +++ /dev/null @@ -1,6 +0,0 @@ -### Added - -- Wallpaper fit options in Settings → Desktop & Dock: fill, fit, stretch, - center, and tile. The choice is persisted per device (localStorage, keyed - by a locally minted device id that is never sent to the server), so each - screen keeps the fit that suits its aspect ratio (#2357). diff --git a/changelog.d/2361-lists-store-cursor-rollback.md b/changelog.d/2361-lists-store-cursor-rollback.md deleted file mode 100644 index 080159d0d..000000000 --- a/changelog.d/2361-lists-store-cursor-rollback.md +++ /dev/null @@ -1,6 +0,0 @@ -### Fixed - -- Project list entries: `get_entry` no longer reads cursor metadata after the - cursor closes, and a failed reorder now rolls back its partial updates so a - later unrelated write cannot commit a half-applied ordering (tsk-u23vjy, - fix-forward of #2183). diff --git a/changelog.d/2363-v1-consent-key-passthrough.md b/changelog.d/2363-v1-consent-key-passthrough.md deleted file mode 100644 index a78d184d0..000000000 --- a/changelog.d/2363-v1-consent-key-passthrough.md +++ /dev/null @@ -1,7 +0,0 @@ -### Fixed - -- The Agent-as-a-Model surface (`GET /v1/models`, `POST /v1/chat/completions`) - is now reachable by external OpenAI-compatible clients: the auth middleware - passes exactly those two routes through to their own consent-key check - instead of rejecting every session-less caller before the handler ran. All - other `/v1` paths remain session-gated (tsk-hfs6zv). diff --git a/changelog.d/fix-auth-status-ua-symmetry.md b/changelog.d/fix-auth-status-ua-symmetry.md deleted file mode 100644 index 4c8bb7c50..000000000 --- a/changelog.d/fix-auth-status-ua-symmetry.md +++ /dev/null @@ -1,9 +0,0 @@ -### Fixed - -- **PWA refresh loop after a browser auto-update**: `/auth/status`, `/auth/me` - and the chat/canvas/terminal/web-chat WebSocket handlers now apply the same - session User-Agent binding check as the API middleware. Previously a session - created before a browser update kept reading as authenticated on - `/auth/status` while every `/api/*` call was rejected, so the desktop shell - remounted in a loop; the WebSocket endpoints conversely accepted a cookie - the APIs refused. diff --git a/changelog.d/tsk-icpt4i-agent-loop-wiring.md b/changelog.d/tsk-icpt4i-agent-loop-wiring.md deleted file mode 100644 index 30d57e50c..000000000 --- a/changelog.d/tsk-icpt4i-agent-loop-wiring.md +++ /dev/null @@ -1,12 +0,0 @@ -### Changed - -- **Agent loop wiring**: `AgentLoop` is now the single per-agent serialization - owner. `AgentChatRouter` drives OpenClaw ACP turns through a per-agent - `AgentLoop` (replacing the per-agent lock) and the turn-holder drives - messages queued mid-turn at its safe point. The desktop taOS agent chat - endpoint serializes on one `AgentLoop` too — fixing a race where two - concurrent POSTs shared the opencode session with no serialization — - queueing concurrent messages and surfacing them in the turn-holder's stream - tail. New `GET /api/taos-agent/status` endpoint returns the desktop loop's - status scoped to state / current turn / queue depth / subagent descriptors - (subagent result/error payloads stay server-side) (#tsk-icpt4i). diff --git a/changelog.d/tsk-laezfg-secret-ignores-gate.md b/changelog.d/tsk-laezfg-secret-ignores-gate.md deleted file mode 100644 index a65337acb..000000000 --- a/changelog.d/tsk-laezfg-secret-ignores-gate.md +++ /dev/null @@ -1,12 +0,0 @@ -### Security - -- **CI**: `secret-ignores-gate` workflow and `scripts/check_secret_ignores.py` now - assert, on push to `master`/`dev`/`release/*` and on PRs to those branches, that the - committed `.gitignore` still contains every secret-protection rule (`*.key`, - `*.p8`, `identity.json`, `*credentials.json`, `*creds*.json`, the `*_private.*` key - shapes, `secrets/`, `data/hub/`, and more) and that known secret-shaped paths - (`data/hub/identity.json`, `foo.key`, `creds.json`, `x.p8`, `y_credentials.json`, - ...) are all reported ignored by `git check-ignore`. Closes the "promotion must be - verified, not assumed" gap from #2171/#2173: a `.gitignore` conflict resolution can - quietly drop a key-material rule while every test stays green. Removing any one - pattern is proven to fail the gate by a parametrized test. diff --git a/changelog.d/tsk-n3w5mh-store-wiring-gate.md b/changelog.d/tsk-n3w5mh-store-wiring-gate.md deleted file mode 100644 index b0bf0a7cc..000000000 --- a/changelog.d/tsk-n3w5mh-store-wiring-gate.md +++ /dev/null @@ -1,11 +0,0 @@ -### Added - -- **CI**: store-wiring-gate workflow and `scripts/check_store_wiring.py` guard. - A PR that adds a new BaseStore subclass without wiring it into - `tinyagentos/app.py` now fails CI and names the unreachable class and file. - Routes reach stores ONLY via `request.app.state`, so an unwired store is - dead code. Start with a NAME-LEVEL check (class name appears in the lifespan - file). Only newly added classes are policed; pre-existing orphans are not - flagged. A `Store-Unwired-Intentionally: , ` trailer in the - PR body waives a named class and logs it, for stores genuinely constructed - elsewhere (tests, CLI, workers). diff --git a/changelog.d/tsk-ppgpln-context-window.md b/changelog.d/tsk-ppgpln-context-window.md deleted file mode 100644 index 3ff41ec0b..000000000 --- a/changelog.d/tsk-ppgpln-context-window.md +++ /dev/null @@ -1,8 +0,0 @@ -### Fixed - -- **Catalog manifests' `context_window` was silently dropped**: `AppManifest` - declared no `context_window` field and `from_dict` never read the YAML - value, so every manifest loaded as 0 and the chat context-window budget code - always fell back to the 4000-token "unknown window" default. The field now - loads onto `AppManifest` (0 reserved for unknown), so real windows — e.g. - rkllm 4096, qwen 32768 — drive the #1740 budget math. (#2338, #1740) diff --git a/changelog.d/tsk-rl2lfb-agent-loop.md b/changelog.d/tsk-rl2lfb-agent-loop.md deleted file mode 100644 index feeca9733..000000000 --- a/changelog.d/tsk-rl2lfb-agent-loop.md +++ /dev/null @@ -1,6 +0,0 @@ -### Added - -- **Agent loop infrastructure**: new `tinyagentos.agent_loop.AgentLoop` library - for subagent delegation and safe-point message queuing. Landed as - standalone infrastructure; wired into the chat router and taOS agent - routes in #tsk-icpt4i (#tsk-rl2lfb). diff --git a/desktop/package.json b/desktop/package.json index ad583b429..32187a70a 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "tinyagentos-desktop", "private": true, - "version": "1.0.0-beta.47", + "version": "1.0.0-beta.48", "type": "module", "scripts": { "dev": "vite", diff --git a/pyproject.toml b/pyproject.toml index 271d9fe9a..82de8626d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "tinyagentos" -version = "1.0.0-beta.47" +version = "1.0.0-beta.48" description = "Self-hosted AI agent memory system for low-power hardware" license = { file = "LICENSE" } # Upper-capped at <3.14 because litellm (the proxy extra, the agent/model proxy diff --git a/tinyagentos/__init__.py b/tinyagentos/__init__.py index 76a4dd6a0..b910d6c70 100644 --- a/tinyagentos/__init__.py +++ b/tinyagentos/__init__.py @@ -1 +1 @@ -__version__ = "1.0.0-beta.47" +__version__ = "1.0.0-beta.48" diff --git a/uv.lock b/uv.lock index c5f3ee940..df9735c6f 100644 --- a/uv.lock +++ b/uv.lock @@ -3063,7 +3063,7 @@ wheels = [ [[package]] name = "tinyagentos" -version = "1.0.0b47" +version = "1.0.0b48" source = { editable = "." } dependencies = [ { name = "aiosqlite" },