Conversation
) * refactor(frontend): sweep auth + channels + top-level views (#554) Slice 4 — bundles three subdomains into one PR per request: AUTH (3 files, 24 migrations) - Login.vue — error icon - SetupPassword.vue — password match indicator, requirement checklist, error banner, 6-tier strength visualization (red/red/orange/yellow/green/green) - MobileAdmin.vue — logout, status dot, fleet running/high-context counts, chat thinking status CHANNELS (5 files, 23 migrations) - PublicLinksPanel — Active badge, expired text, Slack connected/enable/disable/delete icons, form error, delete-confirm modal, success toast - WhatsAppChannelPanel — connected dot, sandbox badge, disconnect btn, webhook warning, success/error message - TelegramChannelPanel — connected dot, disconnect btn, webhook warning, group-remove, success/error message - SlackChannelPanel — connected dot, disconnect btn, success/error - SharingPanel — Approve button, success/error message, remove button TOP-LEVEL VIEWS (4 files, 33 migrations) - Dashboard.vue — running count + dot, message count, clear-tags, connection status dot, history badge, live-feed indicator, message arrow icon - PublicChat.vue — agent online dot, invalid-link icon + bg, agent-unavailable icon + bg, verify error, chat error - OperatingRoom.vue — empty-state success indicator - Templates.vue — error icon 80 token replacements; net diff +80 / -80 (all 1:1 palette aliases). Deferred (30 raw refs remain; all need new token families): - Login: 8 blue (primary action buttons + focus rings) - Dashboard: 10 (blue actions, purple tag-cloud button, blue selected-tab) - OperatingRoom: 5 (blue selected-tab indicators) - PublicChat: 5 (amber AUTO badge, rose READ-ONLY badge, indigo loading) - WhatsApp + Sharing: 2 (amber "deployment prerequisite" notices) These map to pending `action-primary`, `state-selected`, and an accent expansion that's tracked under #555 follow-up territory. Tests: No new specs — these routes are either auth pages (covered by auth.setup), already smoke-tested (Templates), or require fixtures (PublicChat needs a public link token; MobileAdmin lives at /m). Existing 6 @smoke tests cover the high-traffic routes. Refs #554 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(vite): tighten /api proxy prefix to /api/ `/api` (no trailing slash) is a path-prefix match in http-proxy-middleware, so the SPA route `/api-keys` was being captured by the proxy and forwarded to the backend, returning 404 in dev mode. This silently broke the `/api-keys` @smoke e2e test on every PR since the test was added in #597 — that PR's frontend-e2e check failed on merge but wasn't required, so the failure was missed. Backend endpoints all live under `/api/...` (with slash), so the tighter prefix preserves all real proxy traffic and only excludes the SPA route. Verified locally: 7/7 @smoke tests pass after this change (was 6/7 with /api-keys failing). Refs #554 #556 --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… misc (#554) (#623) * refactor(frontend): sweep auth + channels + top-level views (#554) Slice 4 — bundles three subdomains into one PR per request: AUTH (3 files, 24 migrations) - Login.vue — error icon - SetupPassword.vue — password match indicator, requirement checklist, error banner, 6-tier strength visualization (red/red/orange/yellow/green/green) - MobileAdmin.vue — logout, status dot, fleet running/high-context counts, chat thinking status CHANNELS (5 files, 23 migrations) - PublicLinksPanel — Active badge, expired text, Slack connected/enable/disable/delete icons, form error, delete-confirm modal, success toast - WhatsAppChannelPanel — connected dot, sandbox badge, disconnect btn, webhook warning, success/error message - TelegramChannelPanel — connected dot, disconnect btn, webhook warning, group-remove, success/error message - SlackChannelPanel — connected dot, disconnect btn, success/error - SharingPanel — Approve button, success/error message, remove button TOP-LEVEL VIEWS (4 files, 33 migrations) - Dashboard.vue — running count + dot, message count, clear-tags, connection status dot, history badge, live-feed indicator, message arrow icon - PublicChat.vue — agent online dot, invalid-link icon + bg, agent-unavailable icon + bg, verify error, chat error - OperatingRoom.vue — empty-state success indicator - Templates.vue — error icon 80 token replacements; net diff +80 / -80 (all 1:1 palette aliases). Deferred (30 raw refs remain; all need new token families): - Login: 8 blue (primary action buttons + focus rings) - Dashboard: 10 (blue actions, purple tag-cloud button, blue selected-tab) - OperatingRoom: 5 (blue selected-tab indicators) - PublicChat: 5 (amber AUTO badge, rose READ-ONLY badge, indigo loading) - WhatsApp + Sharing: 2 (amber "deployment prerequisite" notices) These map to pending `action-primary`, `state-selected`, and an accent expansion that's tracked under #555 follow-up territory. Tests: No new specs — these routes are either auth pages (covered by auth.setup), already smoke-tested (Templates), or require fixtures (PublicChat needs a public link token; MobileAdmin lives at /m). Existing 6 @smoke tests cover the high-traffic routes. Refs #554 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(vite): tighten /api proxy prefix to /api/ `/api` (no trailing slash) is a path-prefix match in http-proxy-middleware, so the SPA route `/api-keys` was being captured by the proxy and forwarded to the backend, returning 404 in dev mode. This silently broke the `/api-keys` @smoke e2e test on every PR since the test was added in #597 — that PR's frontend-e2e check failed on merge but wasn't required, so the failure was missed. Backend endpoints all live under `/api/...` (with slash), so the tighter prefix preserves all real proxy traffic and only excludes the SPA route. Verified locally: 7/7 @smoke tests pass after this change (was 6/7 with /api-keys failing). Refs #554 #556 * refactor(frontend): sweep cross-cutting + chat + file-mgr + process + misc (#554) Slice 7 stacked on #609. Migrates 24 files spanning the small-domain pool: CHAT (4 files) ChatBubble — copy-success checkmark, self-task accent panel (purple) ChatPanel — agent-not-running warning state, error banner ChatInput — file-remove button, voice-active recording indicator ChatHistoryDropdown — error state FILE MANAGER (4 files) FileManager — notification toast (success/error), no-agents warning, loading error, delete button + modal + confirm action FileTreeNode — search-matched row highlight (file-type icons stay raw decorative; need their own accent palette later) FilePreview — preview-error icon + text FileSharingPanel — Revoke button PROCESS (3 files) TrendChart — completed/failed/cost bars (chart series + legend), success-rate threshold ladder RoleMatrix — no-executor row + badge TemplateSelector — category badges (business/devops/support → status-info / accent-purple / status-urgent) CROSS-CUTTING / MODALS (10 files) NavBar — Ops critical-pulse + high indicator, WS connected dot GitConflictModal — yellow warning header (×2), all destructive (red) options ReplayTimeline — system-agent purple panel + badge, schedule-marker arrow, live-feed dot, activity-state success rate ladder UnifiedActivityPanel — running/success/fail indicators (live + modal) OnboardingChecklist — completed-state styling (ring, bg, indicator, text) CreateAgentModal — templates-error + general error ConfirmDialog — danger/warning variant icons, text, confirm buttons ResourceModal — (no migrations — amber notice, deferred) AvatarGenerateModal — error text, remove-avatar button HelpChatWidget — error banner + retry button MISC (3 files) YamlEditor — error and warning banners + counts + success checkmark EditorHelpPanel — required-field indicator TerminalPanelContent — restart-required notice, start-agent button TagsEditor — error message Net diff: 24 files, +106 / -106 (1:1 palette aliases, byte-identical CSS). Deferred (existing pattern): - Indigo / blue primary action buttons (Login & elsewhere) - Blue selected-state (NavBar tabs, OperatingRoom tabs) - Amber notices (ResourceModal, RoleMatrix amber missing-role marker — these still use the amber palette which differs from yellow) - File-type icon colors in FileTreeNode (decorative, need accent-yellow / accent-purple-blue / etc. — folder ≠ warning, video ≠ accent, etc.) - Slack/Telegram/WhatsApp logo brand colors These map to the pending `action-primary`, `state-selected`, and accent-color- expansion tickets. Verified locally: - npm run check:tokens passes (10 tokens valid) - npm run build passes - npm run test:e2e:smoke (7 tests, 7.9s) all green Refs #554 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(frontend): sweep auth + channels + top-level views (#554) Slice 4 — bundles three subdomains into one PR per request: AUTH (3 files, 24 migrations) - Login.vue — error icon - SetupPassword.vue — password match indicator, requirement checklist, error banner, 6-tier strength visualization (red/red/orange/yellow/green/green) - MobileAdmin.vue — logout, status dot, fleet running/high-context counts, chat thinking status CHANNELS (5 files, 23 migrations) - PublicLinksPanel — Active badge, expired text, Slack connected/enable/disable/delete icons, form error, delete-confirm modal, success toast - WhatsAppChannelPanel — connected dot, sandbox badge, disconnect btn, webhook warning, success/error message - TelegramChannelPanel — connected dot, disconnect btn, webhook warning, group-remove, success/error message - SlackChannelPanel — connected dot, disconnect btn, success/error - SharingPanel — Approve button, success/error message, remove button TOP-LEVEL VIEWS (4 files, 33 migrations) - Dashboard.vue — running count + dot, message count, clear-tags, connection status dot, history badge, live-feed indicator, message arrow icon - PublicChat.vue — agent online dot, invalid-link icon + bg, agent-unavailable icon + bg, verify error, chat error - OperatingRoom.vue — empty-state success indicator - Templates.vue — error icon 80 token replacements; net diff +80 / -80 (all 1:1 palette aliases). Deferred (30 raw refs remain; all need new token families): - Login: 8 blue (primary action buttons + focus rings) - Dashboard: 10 (blue actions, purple tag-cloud button, blue selected-tab) - OperatingRoom: 5 (blue selected-tab indicators) - PublicChat: 5 (amber AUTO badge, rose READ-ONLY badge, indigo loading) - WhatsApp + Sharing: 2 (amber "deployment prerequisite" notices) These map to pending `action-primary`, `state-selected`, and an accent expansion that's tracked under #555 follow-up territory. Tests: No new specs — these routes are either auth pages (covered by auth.setup), already smoke-tested (Templates), or require fixtures (PublicChat needs a public link token; MobileAdmin lives at /m). Existing 6 @smoke tests cover the high-traffic routes. Refs #554 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(vite): tighten /api proxy prefix to /api/ `/api` (no trailing slash) is a path-prefix match in http-proxy-middleware, so the SPA route `/api-keys` was being captured by the proxy and forwarded to the backend, returning 404 in dev mode. This silently broke the `/api-keys` @smoke e2e test on every PR since the test was added in #597 — that PR's frontend-e2e check failed on merge but wasn't required, so the failure was missed. Backend endpoints all live under `/api/...` (with slash), so the tighter prefix preserves all real proxy traffic and only excludes the SPA route. Verified locally: 7/7 @smoke tests pass after this change (was 6/7 with /api-keys failing). Refs #554 #556 * refactor(frontend): sweep cross-cutting + chat + file-mgr + process + misc (#554) Slice 7 stacked on #609. Migrates 24 files spanning the small-domain pool: CHAT (4 files) ChatBubble — copy-success checkmark, self-task accent panel (purple) ChatPanel — agent-not-running warning state, error banner ChatInput — file-remove button, voice-active recording indicator ChatHistoryDropdown — error state FILE MANAGER (4 files) FileManager — notification toast (success/error), no-agents warning, loading error, delete button + modal + confirm action FileTreeNode — search-matched row highlight (file-type icons stay raw decorative; need their own accent palette later) FilePreview — preview-error icon + text FileSharingPanel — Revoke button PROCESS (3 files) TrendChart — completed/failed/cost bars (chart series + legend), success-rate threshold ladder RoleMatrix — no-executor row + badge TemplateSelector — category badges (business/devops/support → status-info / accent-purple / status-urgent) CROSS-CUTTING / MODALS (10 files) NavBar — Ops critical-pulse + high indicator, WS connected dot GitConflictModal — yellow warning header (×2), all destructive (red) options ReplayTimeline — system-agent purple panel + badge, schedule-marker arrow, live-feed dot, activity-state success rate ladder UnifiedActivityPanel — running/success/fail indicators (live + modal) OnboardingChecklist — completed-state styling (ring, bg, indicator, text) CreateAgentModal — templates-error + general error ConfirmDialog — danger/warning variant icons, text, confirm buttons ResourceModal — (no migrations — amber notice, deferred) AvatarGenerateModal — error text, remove-avatar button HelpChatWidget — error banner + retry button MISC (3 files) YamlEditor — error and warning banners + counts + success checkmark EditorHelpPanel — required-field indicator TerminalPanelContent — restart-required notice, start-agent button TagsEditor — error message Net diff: 24 files, +106 / -106 (1:1 palette aliases, byte-identical CSS). Deferred (existing pattern): - Indigo / blue primary action buttons (Login & elsewhere) - Blue selected-state (NavBar tabs, OperatingRoom tabs) - Amber notices (ResourceModal, RoleMatrix amber missing-role marker — these still use the amber palette which differs from yellow) - File-type icon colors in FileTreeNode (decorative, need accent-yellow / accent-purple-blue / etc. — folder ≠ warning, video ≠ accent, etc.) - Slack/Telegram/WhatsApp logo brand colors These map to the pending `action-primary`, `state-selected`, and accent-color- expansion tickets. Verified locally: - npm run check:tokens passes (10 tokens valid) - npm run build passes - npm run test:e2e:smoke (7 tests, 7.9s) all green Refs #554 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(frontend): sweep agent surfaces — 22 files (#554) Slice 8 of the design-system migration. Replaces semantic palette refs with status/state/accent tokens across all per-agent panels, the agents list, and agent-detail surfaces. Files migrated: - 16 panels (Tasks, Git, Dashboard, Playbooks, Nevermined, Info, Credentials, Schedules, SystemViewEditor, HostTelemetry, Folders, Files, Skills, Observability, Permissions, Metrics) - 5 agent surfaces (Agents, AgentNode, AgentHeader, AgentTerminal, AgentDetail) - SystemAgentNode Mappings (palette-equivalent, no visual change): - yellow → status-warning - green → status-success - red → status-danger - orange → status-urgent - amber → state-autonomous (token name slightly stretched for tool-call / queued-task amber; same palette, future cleanup may rename) - rose → state-locked - purple → accent-purple Deferred (no token family yet): - indigo (action-primary) - blue/sky/cyan (selected-state, category labels) - teal (category labels) SystemViewsSidebar untouched — only contains deferred blue/indigo selected-state references. Verification: - npm run check:tokens → 10 tokens equivalent, all references resolve - npm run build → clean - npm run test:e2e:smoke → 7/7 passed against live Trinity (HMR) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…d stdout pipe open (#618) (#620) * fix(voice): orb animation loop dies before voice session starts The canvas is inside v-if="voice.isActive.value" so canvasEl.value is null when onMounted fires. renderFrame() exits early without scheduling the next frame, killing the loop permanently. Replace onMounted initialization with watch(canvasEl) so the RAF loop starts when the canvas enters the DOM and stops when it leaves. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(agent-runtime): kill npx MCP orphans outside claude pgid that hold stdout pipe open (#618) After terminate_process_group kills claude's pgid, npm→node MCP server chains spawned via npx call setsid() and land in a new session — they survive the pgid kill and keep the stdout pipe write FD open indefinitely. The kernel cannot deliver EOF to our reader thread while any writer FD remains open, so drain_reader_threads blocked for the full 30s post_kill_grace, then lost the buffered result line via force-close (HTTP 502). Add _kill_orphan_pipe_writers(): after terminate_process_group, scan /proc/*/fd for any process outside our pgid that holds the pipe's write end (detected via fdinfo flags), and SIGKILL it. Killing the orphan releases all its FDs (stdout AND stderr write ends) simultaneously, delivering EOF to both reader threads so they drain naturally before the post_kill_grace window. New tests (Linux-only, skipped on macOS — /proc required): verify that a setsid() grandchild is detected and killed, that our own read-end process is not touched, and that the end-to-end drain path preserves buffered data. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces slackify-markdown with a custom renderer fixing 5 compounding bugs (nested lists, headings, blockquotes, tables, horizontal rules). Includes 35 unit tests and updated feature flow doc. Closes #293 Co-Authored-By: pavshulin <pavshulin@users.noreply.github.com>
Adds a token usage row to AgentHeader showing 7-day cost sparkline,
today's cost vs 7-day daily average (with trend arrow), and lifetime
totals. Data sourced from schedule_executions in the DB so it persists
across agent restarts.
- New GET /api/agents/{name}/token-stats endpoint
- ScheduleOperations.get_agent_token_stats(): single-pass 24h/7d/lifetime
aggregation + 7-day daily breakdown with gap-filling
- agentsStore.getAgentTokenStats() action
- TOKEN USAGE ROW in AgentHeader.vue: SparklineChart (amber, 56x16),
trend indicator (warning/success/gray), lifetime summary
- Hidden for agents with no runs (lifetime_executions == 0)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
…#634) Adds live HTTP reverse-proxy so agents can serve public websites from their container. A new `type='site'` public link routes requests through `GET /site/{token}/{path}` → httpx streaming proxy → agent web server at `http://agent-{name}:3000`. Includes DB migration, nginx routing, rate limiting (per-IP + per-token), SSRF guard, security header stripping, and audit event `site_link_visit`. UI adds Chat/Website selector in the link create modal with a "Website" badge on site links. Fixes #633 Co-authored-by: Claude <noreply@anthropic.com>
…udit log, update docs (SITE-001) - Move SITE_PORT to config.py; import in site.py and public_links.py - Fix TOCTOU race in _check_site_rate_limit: pipeline INCR+check-after - Audit log is now asyncio.create_task() so streaming is not delayed - Add SITE-001 to requirements.md (section 15.1a-3) - Add site.py to architecture.md router listing + /site/ endpoint table Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… drain stall (#649) (#650) /proc scanning can block indefinitely when a process is in D state (uninterruptible sleep), causing drain_reader_threads to stall for tens of minutes instead of the expected ~30 seconds. Run _kill_orphan_pipe_writers in a daemon thread with a 10s cap so a blocked /proc entry cannot push the drain past its deadline. Also use wall-clock accounting for the post-kill join timeout so time spent in terminate + orphan scan doesn't silently erode the budget, and log actual elapsed time instead of the expected value so future incidents are easier to diagnose. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
#600) (#638) The /ws/voice/{voice_session_id} handler decoded the JWT but threw the payload away — only the signature was checked. Any authenticated user holding a valid JWT who learned the 128-bit session id (logs, browser inspection, XSS) could attach to the audio stream, eavesdrop on the victim's transcript, and trigger tool calls audit-logged under the victim's identity. POST /api/agents/{name}/voice/stop had the same gap: the path agent was gated via get_authorized_agent, but request.voice_session_id was never cross-checked against the path agent or the caller's user_id, so the caller could end and persist a transcript onto another user's session. Fix: - WS: extract sub from the decoded JWT, look up the user, and close 4003 if user.id != session.user_id (admin role bypasses, for support). - voice_stop: load the session via get_session before mutating, assert agent_name == path name AND user_id == current_user.id (admin bypasses), raise 403 otherwise. Added tests/unit/test_voice_auth.py covering: missing token, invalid token, missing sub claim, unknown user, owner happy path, admin bypass, attacker rejected, plus voice_stop variants. Loads voice.py via importlib to avoid pulling in the full routers/__init__.py chain. Reported by /security-review on PR #599 (2026-04-30); origin commit 7d8abe8 (#581 voice tool calls). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…591) (#621) Pentest finding AISEC-H2 (CVSS 7.5, CWE-307): the previous design used a single per-IP bucket at 5 fails / 10 min. Any user behind a corporate NAT, VPN, or CDN locked out everyone else at the same egress IP after just four bad attempts. A rotating-proxy attacker could keep an organisation locked out continuously, so the protection doubled as a platform-wide DoS primitive. Replace with two independent buckets: * Per-account (tight) — 5 fails / 15 min: limits credential stuffing on one targeted account; never affects other accounts. * Per-IP (loose) — 30 fails / 5 min: catches single-source abuse but stays well above the legitimate-traffic threshold for users sharing a NAT/VPN/CDN egress. Both buckets are checked on every attempt; 429 fires when either is exhausted. Successful login clears both. Account names are normalised (lowercase + strip) before keying. Endpoints without an account context (public access-request) skip the per-account bucket and rely on the per-IP one only. Lockout state-changes log a structured WARNING (visible via Vector) so operators can see when buckets are being exercised. Live verification on the running backend: attempts 1-5 → 401 (counter ticking) attempt 6 → 429 "Too many failed attempts for this account..." valid pwd → 429 (account stays locked even with right password) other account from same IP → 401 (per-account isolation works) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Auditing all entry points that ultimately call `create_agent_internal` (per #592 AC #2) turned up a real bypass on `POST /api/systems/deploy`. The system-manifest deployment route gated on `Depends(get_current_user)` without a role check, so any authenticated user-role account could spawn an entire fleet of agents through that path — a strictly stronger privilege than the single-agent bypass the AISEC-H1 finding originally named (which `POST /api/agents/deploy-local` had already closed via #150). Add `Depends(require_role("creator"))` to `deploy_system`, matching the existing dependencies on `POST /api/agents` and `POST /api/agents/deploy-local`. Regression test (`tests/unit/test_agent_creation_role_gates.py`) walks the FastAPI router source AST and asserts that every agent-creation route uses `Depends(require_role("creator"))`. AST-level so the check is fast, stable across formatting changes, and fires the moment someone removes the dependency. Confirmed the test catches the regression by reverting the change and observing the test fail before re-applying. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(security): stop mirroring JWT into document.cookie (#188) UnderDefense pentest 3.3.5 flagged the frontend for mirroring the authentication token into a `token` cookie without `Secure` or `HttpOnly` flags. The cookie was set in setupAxiosAuth via `document.cookie =` so it was readable from JS (HttpOnly is impossible on JS-set cookies), transmitted over HTTP without the Secure flag, and auto-attached to every outbound request as a CSRF vector. The cookie's stated purpose was "for nginx auth_request to validate agent UI access" — but that nginx directive was never configured in any committed deployment (`grep -r auth_request -- *.conf` is empty, git log -S confirms it never existed). The cookie was pure attack surface with zero functional value. Per the issue's "Best" remediation, drop the cookie mirror entirely. API authentication uses the `Authorization: Bearer` header exclusively; nothing else needs the cookie. The cookie-clear on logout is intentionally kept so users carrying a stale cookie from the pre-fix version get cleaned up on their next logout cycle. The cookie's `max-age=1800` also naturally expires it within 30 minutes of the upgrade. The backend's `/api/auth/validate` endpoint still accepts a cookie as one of three token sources — left untouched as out-of-scope. With the frontend no longer setting the cookie nothing legitimate sends one, but the fallback path remains available if a future nginx auth_request setup is wired up properly (with Secure + HttpOnly flags set server-side via Set-Cookie, not via document.cookie). Closes #188. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(admin-login): remove stale references to JWT cookie mirror (#188) PR #642 removed the document.cookie set in setupAxiosAuth, but the admin-login feature flow still documented the cookie as live. Update the code snippet and the storage table to reflect current behaviour. Note in the snippet describes why the cookie was removed so readers who see the diff history can find the rationale without reading the PR. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…646) UnderDefense pentest 3.3.1 flagged the backend for leaking the underlying tech stack via outbound User-Agent. Skills sync uses git subprocess (not httpx), so the leaked UA is `git/<version> (libcurl/<version> ...)` — verified live with GIT_TRACE_CURL=1. Add `-c http.useragent=Trinity-Skills-Sync` (positioned correctly before the subcommand, as git's `-c` requires) to the two HTTP-bearing git invocations: `_git_clone` and `_git_pull`'s fetch. The local-only `git reset --hard` and `git rev-parse HEAD` calls intentionally do not get the flag — they make no HTTP and threading the flag through would suggest otherwise. The SSRF allowlist (#179) already locks the destination to github.com so the practical exposure is small (GitHub already knows what we are), but defense-in-depth: even if the allowlist is ever loosened the UA stays generic. The constant has no version suffix to avoid yet another version string drifting against VERSION / package.json / pyproject. Tests in tests/unit/test_skill_service_user_agent.py mock subprocess and assert the flag is present at the right argv position for clone and fetch, and absent for the local-only reset and rev-parse calls. Live verification with `GIT_TRACE_CURL=1 git -c http.useragent=... ls-remote ...` confirms the wire UA changes from `git/2.43.0` to `Trinity-Skills-Sync`. Closes #184. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… DEPLOYMENT.md (#557) (#626) Self-hosted developers tracking `dev` occasionally pull a commit that adds a new Python or Node dependency to one of the platform Dockerfiles, re-run `start.sh`, and end up with new source running against an old image's Python env. Uvicorn crashes with `ModuleNotFoundError`, compose keeps respawning the worker, and `start.sh` reports success — leaving the UI "Disconnected" with no obvious diagnosis. Adopting Option B from #557 discussion (PR #625 closed): treat this as a documentation problem rather than building auto-detection into the critical-path startup script. Auto-detection has clear cost (Python subprocess + Docker inspect on every cold start) and unclear benefit (production deploys use `compose pull` and don't hit this; the affected population is self-hosted devs whose recovery is one command). Two changes: - `scripts/deploy/start.sh`: append a 4-line hint after the "Ready!" banner naming the symptom (`ModuleNotFoundError`, "Disconnected" UI) and the exact recovery command. - `docs/DEPLOYMENT.md`: add a Troubleshooting entry with full diagnosis walkthrough, root-cause explanation, and the rationale for not auto-detecting (links to #557). A future `scripts/deploy/upgrade.sh` is the right place to bundle backup + rebuild + start + verify for the explicit upgrade path; that is bigger-than-#557 scope. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(security): split Docker compose into platform and agent networks (#589) Redis at 172.28.0.0/16 was reachable from any agent container. AISEC scan 3aad5469 demonstrated end-to-end exfiltration / cross-user task injection from a legitimately deployed agent. Network segmentation is the strongest control — agents now physically cannot route to Redis. Topology: - trinity-platform (172.29.0.0/16, NEW) — Redis, scheduler, vector - trinity-agent (172.28.0.0/16, name preserved) — frontend, agents - Backend / mcp-server / otel-collector / cloudflared straddle both Agent-creation sites in services/agent_service/* and system_agent_service.py need zero changes because the agent-network external name is preserved. Dev: bind Redis host port to 127.0.0.1:6379 (was 0.0.0.0). Tests connect from the dev machine; LAN cannot. Auth lands in the next commit. Refs #589 — acceptance criterion #3 (network segment separation). * fix(security): mandatory Redis auth, ACL users, auth-aware healthcheck (#589) Both compose files now enforce two passwords (REDIS_PASSWORD admin / REDIS_BACKEND_PASSWORD runtime) with the fail-on-missing :? form. docker compose refuses to render without them. Per-user ACL via inline --user flags. Additive (start from zero, allow only what the runtime needs) — never +@ALL -X, which lets newly added dangerous commands through. backend + scheduler get standard data families plus scripting/transactions/pubsub minus -@dangerous, which covers FLUSHALL, CONFIG, SHUTDOWN, MIGRATE, REPLICAOF, MONITOR. Verified at runtime against redis:7-alpine: PING/SET/GET work for the backend user, FLUSHALL and CONFIG GET return NOPERM, unauth requests return NOAUTH. REDIS_URL on backend + scheduler now embeds the backend ACL user. mcp-server: REDIS_URL and depends_on:redis dropped in prod compose (zero Redis imports in src/mcp-server/). Healthcheck pings as the backend ACL user so a typo'd ACL keeps redis unhealthy and gates dependent services. depends_on:redis switches to service_healthy so backend/scheduler don't race the ACL load. Refs #589 — acceptance criteria #1, #2, #5. * fix(scheduler-test-rig): mirror Redis auth posture (#589) Without this, scheduler container fails fast on startup against the rig because src/scheduler/config.py requires creds in REDIS_URL after #589. No ACL or network split here — this is a 2-service standalone debugging rig, not the production posture. * fix(security): fail-fast on REDIS_URL missing credentials (#589) Backend (src/backend/config.py) and scheduler (src/scheduler/config.py) now raise RuntimeError at import time if REDIS_URL is unset or lacks credentials. Removed the splicing fallback in backend config that papered over an unauth REDIS_URL by joining REDIS_PASSWORD into the URL — single source of truth (compose) eliminates silent drift. Tests that import backend modules need a creds-bearing REDIS_URL in their environment; tests/conftest.py will set a dummy one in the test commit. Refs #589 — acceptance criterion #5. * fix(webhooks): use REDIS_URL for rate-limit client (#589) Webhooks rate-limit was the one Redis client that bypassed REDIS_URL — it used redis.Redis(host="redis", port=6379) and would silently fail-open under requirepass. Switching to redis.from_url(REDIS_URL) picks up the credentialed URL like every other client. Also: distinguish auth/ACL errors (logged at ERROR with exception class) from transient errors (WARN). Fail-open behavior preserved so a Redis blip doesn't 500 legitimate webhooks, but a misconfigured deploy now surfaces in alerts instead of via a webhook abuse incident. Drops the now-unused REDIS_HOST/REDIS_PORT env reads. * feat(deploy): auto-generate Redis passwords on fresh installs (#589) start.sh ensure_redis_passwords matches the existing CREDENTIAL_ENCRYPTION_KEY pattern, with one safety guard: - Fresh install (no redis-data volume) → generate both passwords with openssl rand -hex 24 and append to .env. One-command boot keeps working. - Existing volume + missing password → refuse with a loud error pointing at docs/migrations/REDIS_AUTH.md. Re-keying a populated Redis would lock the backend out of its own data; ops needs to follow the explicit upgrade path. Idempotent — second run is a no-op when both passwords are already set. * docs(security): add Redis auth migration guide + architecture notes (#589) - docs/migrations/REDIS_AUTH.md: operator upgrade guide. Covers fresh installs (auto-generated by start.sh), live upgrades (down --remove-orphans + docker network rm + add passwords), production, and verification commands. - docs/memory/architecture.md: new "Network Topology (Issue #589)" section above Container Security. Documents the two-network split, service membership table, the "agents NEVER on platform network" rule, and the three Redis ACL users + their access patterns. * test(security): network isolation, ACL, fail-fast, webhook rate-limit (#589) tests/conftest.py: top-level autouse env stub for backend imports. Backend config now raises at import-time if REDIS_URL lacks credentials; without this, every test that transitively imports backend modules breaks. Real Redis tests under tests/security/ override via their own conftest from .env. Adds the `integration` marker. tests/unit/test_config_fail_fast.py (new): backend refuses to import without creds-bearing REDIS_URL. 3 cases — missing env, unauth URL, URL with creds. tests/security/test_redis_network_isolation.py (new): 5 integration tests covering acceptance criteria #1-#3: - agent-network container has no route to redis (BLOCKED) - unauth client gets NOAUTH on platform network - backend ACL user can PING with creds - backend ACL user FLUSHALL → NOPERM (no admin) - backend ACL user CONFIG GET → NOPERM (no requirepass leak) tests/security/conftest.py (new): session-scoped fixture loads real .env values for the integration tests; skips the suite if missing. tests/integration/test_webhook_rate_limit.py (new): regression for the from_url switch in webhooks.py. Self-contained — creates agent + schedule + webhook token inline, hits 11×, expects 429 on the 11th. Catches the silent fail-open if Redis auth ever regresses. tests/run-integration.sh (new): pytest -m integration runner. Excluded from run-smoke.sh per the smoke runner's ~30s no-Docker contract. * docs(security): detach agents before network rm (#589) Trinity-managed agent containers are created via the Docker SDK outside compose, so they store the agent network's UUID, not its name. After `docker network rm trinity-agent-network` (step 3 of the upgrade procedure), any later `docker start <agent>` fails: Error response from daemon: failed to set up container networking: network <old-uuid> not found Compose-managed services don't hit this — they're recreated with fresh network refs on `up`. Agent containers aren't, so they keep the stale UUID until disconnected. Add an explicit detach loop as step 2, before the network removal. Verified against a populated install with one running and four stopped agents: all five reattach cleanly to the new network on next start. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(security): CSO OBS-1/2/3 follow-ups — webhook rate-limit + healthcheck hardening (#589) Resolves three observations from the CSO audit (docs/security-reports/cso-2026-05-04-589-diff.md): OBS-1 — webhook rate-limit fail-open + connection-per-request DoS amplifier: * Added in-process secondary rate limiter (3x primary, per-worker) in src/backend/routers/webhooks.py. Bounds blast radius during a Redis outage without breaking the documented fail-open philosophy. * Cached the Redis client at module level under threading.Lock with double-checked init. _check_webhook_rate_limit resets the cache on inner exceptions so stale connections rebuild cleanly. Without caching, a flood would open a fresh TCP per request and exhaust Redis maxclients — turning the rate limiter into the DoS amplifier. OBS-2 — tightened _TOKEN_RE from {20,60} to {43} matching secrets.token_urlsafe(32) (verified against db/schedules.py:524). OBS-3 — switched all three compose healthchecks from `redis-cli -a $$PASS` to `REDISCLI_AUTH="$$PASS" redis-cli` so the password no longer appears in /proc/<pid>/cmdline. Additional #589 hardening (caught while resolving OBS-1): * src/backend/config.py + src/scheduler/config.py: tightened the REDIS_URL credential check from `"@" in url` substring to urlparse validation. Catches redis://@redis:6379, redis://user@redis:6379, etc. * src/scheduler/main.py: redact password from REDIS_URL before logging (was leaking via Vector log aggregator). Tests: * tests/unit/test_webhook_rate_limit_inprocess.py — 7 new tests covering cap, window expiry, token isolation, runtime-error fallback, regex shape, cache hit, cache reset. * tests/unit/test_config_fail_fast.py — 4 new parametrized cases for malformed-credential URL rejection. * 15/15 unit tests pass. * Live Redis healthcheck verified — trinity-redis reports healthy with the new REDISCLI_AUTH form; `redis-cli ping` returns PONG. Also adds .gstack/ to .gitignore so future skill artifacts stay local. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
trinity-network no longer exists; gitea dev overlay must attach to trinity-agent-network (the preserved agent-network name). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* docs(planning): add Session tab design — --resume-default chat surface
Adds docs/planning/SESSION_TAB_2026-04.md, the comprehensive plan for a
new "Session" tab living alongside Chat. Sessions reattach to their own
Claude Code JSONL via --resume, preserving tool memory, mid-skill state,
and reasoning state across turns.
Plan covers:
- UI design (tab placement, multi-session model, +New Session, Reset memory)
- Data model (agent_sessions / agent_session_messages — parallel to chat)
- Backend architecture (separate router, single shared change to
task_execution_service for persist_session plumbing)
- Phased rollout (foundation → backend → frontend → hardening → GA)
- Edge cases & failure-mode lessons baked in from a prior local spike
(parser bug, --no-session-persistence dependency, cold-turn detection,
port allocation)
- Test plan including the cross-session contamination test for
Anthropic claude-code#26964
- Retention/cleanup policy, observability, security checklist
- Local-first workflow: implementation runs entirely on this branch
until validation passes; only then does the standard SDLC engage
(issue, push, PR)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(db): add agent_sessions + agent_session_messages tables
Phase 1.1 of the Session tab plan (docs/planning/SESSION_TAB_2026-04.md).
Schema definitions go in db/schema.py for fresh installs; the matching
idempotent migration agent_sessions_tables in db/migrations.py upgrades
existing databases.
The schema mirrors chat_sessions / chat_messages but is strictly parallel
— no foreign keys, no shared columns, separate index namespace. Three
fields are unique to the session model:
- agent_sessions.cached_claude_session_id — the Claude Code session UUID
the next turn will pass to ``--resume``
- agent_sessions.consecutive_resume_failures — drives the resume-failure
fallback (Phase 2.2)
- agent_session_messages.cache_read_tokens — observability for whether
Anthropic's prompt cache engaged
CASCADE on session delete cleans up message rows automatically.
Verified locally: backend restart applies the migration cleanly, tables
have 15 columns each with correct types/defaults/PKs, all four indexes
created, second restart confirms idempotency.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(db): add SessionOperations for Session tab persistence
Phase 1.2 of the Session tab plan (docs/planning/SESSION_TAB_2026-04.md).
- Adds AgentSession and AgentSessionMessage Pydantic models in db_models.py
with the new fields the Session tab needs beyond ChatSession/ChatMessage:
cached_claude_session_id, last_resume_at, consecutive_resume_failures on
the session row, and cache_read_tokens + claude_session_id on each message.
- Creates db/sessions.py with a SessionOperations class mirroring the
ChatOperations shape: create_session, get_session, list_sessions,
delete_session, add_session_message, get_session_messages, plus the
Claude UUID cache helpers (get/update/clear_cached_claude_session_id)
and resume health helpers (mark_resume_failure, mark_resume_success).
- Wires the new ops into the DatabaseManager facade alongside the
existing _chat_ops, with one delegating method per public operation.
No router, no agent-server change, no frontend yet — those land in later
phases. Tables agent_sessions and agent_session_messages already exist
from the prior schema commit.
* feat(session-tab): backend foundation for --resume-default Session surface
Phases 1.3 through 1.7 of the Session tab plan
(docs/planning/SESSION_TAB_2026-04.md). Pure backend / agent-server work
behind a flag — no UI surface yet, no behavior change to Chat or any
existing /task caller.
Agent server (base image):
- Stream-json parser fix (Appendix B). Both parse_stream_json_output and
process_stream_line now recognize {"type":"system","subtype":"init"}
for session_id capture, with the result event as a fallback when init
was missed (truncated streams). The legacy bare-init shape is
intentionally rejected. This is the same bug that would have made
Session caching corrupt on every cold turn.
- Same bug in execute_headless_task's permission-mode validation site:
the check matched the wrong shape, so permission_mode_validated never
flipped to True and the protective kill-on-misconfigured-permission
path silently failed open. Now uses type=system + subtype=init.
- New persist_session flag threaded through ParallelTaskRequest →
routers/chat.py → AgentRuntime ABC → ClaudeCodeRuntime.execute_headless
→ execute_headless_task. When True, --no-session-persistence is
omitted so the JSONL is written and the next turn's --resume can find
it. --session-id is still passed for unique cold-turn namespace.
Default False keeps every existing caller stateless.
- gemini_runtime accepts the parameter for ABC parity and ignores it
(Gemini CLI has no resume).
Backend:
- task_execution_service.execute_task now accepts persist_session: bool
= False and threads it into the agent payload. All existing callers
(Chat, schedules, MCP, fan-out, webhooks) keep today's behavior; only
the future routers/sessions.py (Phase 2) opts in.
- settings_service.is_session_tab_enabled() — feature flag resolving
system_settings.session_tab_enabled → SESSION_TAB_ENABLED env →
False. Module-level convenience function exposed.
Tests (run inside trinity-backend container — Python 3.11):
- tests/unit/test_session_operations.py — 9 tests against an isolated
SQLite DB exercising the full SessionOperations CRUD plus the cached
claude session UUID lifecycle and resume failure / success counters.
- tests/unit/test_claude_code_session_id_parser.py — 8 tests covering
both parsers (batch + streaming): system/init recognition, result
fallback, init-wins-over-result, legacy bare-init rejection, and a
source-level regression guard for the permission-mode validation
fix.
- tests/unit/test_session_persistence_flag.py — 8 tests pinning the
contract: signatures across the runtime ABC, ParallelTaskRequest,
agent chat router, execute_headless_task, and
task_execution_service.execute_task. Includes the gating regex check
on --no-session-persistence and a live signature import to catch
drift AST parsing alone would miss.
Total: 25 passing tests covering every touchpoint of Phase 1.
Base image (trinity-agent-base) rebuilt to embed the agent-server
changes; existing agent containers will pick them up on next recreate.
* feat(session-tab): backend turn endpoint for --resume-default Session surface
Phase 2 of docs/planning/SESSION_TAB_2026-04.md. Six endpoints under
/api/agents/{name}/session{s,...} that mirror routers/chat.py's auth
model and TaskExecutionService usage but persist to the parallel
agent_sessions / agent_session_messages tables and request
persist_session=True on every turn so each call reattaches via
`claude --print --resume <uuid>`.
Surface gated on is_session_tab_enabled() — flag-off default returns
404 from every endpoint.
POST /api/agents/{name}/session create row
GET /api/agents/{name}/sessions list (per-user)
GET /api/agents/{name}/sessions/{id} session + messages
POST /api/agents/{name}/sessions/{id}/message THE turn
POST /api/agents/{name}/sessions/{id}/reset clear cached uuid
DELETE /api/agents/{name}/sessions/{id} delete row + msgs
Spike-pitfall defenses baked into the turn endpoint:
- L3 (first-turn-has-no-session-id): the agent_sessions row is created
server-side via POST /session BEFORE the turn endpoint ever calls
execute_task. No frontend-first model.
- L2 (cold turn writes empty JSONL): persist_session=True is passed
unconditionally — Phase 1.4 already wired the flag through the agent
stack; Phase 2 just promises to set it on every turn.
- L1 (parser misses system/init): trust result.session_id directly —
Phase 1.3 fixed the parser. Scenario A confirms the captured UUID is
the real Claude UUID end-to-end.
Phase 2.2 resume-failure fallback: when execute_task returns "no
conversation found" on a turn that had a cached UUID, clear the cache,
mark_resume_failure, and retry once with resume_session_id=None. Logs
event=session_resume_fallback with the stale UUID and consecutive
failure count. Anthropic #39667 (cleanupPeriodDays) and #53417 (CLI
upgrade) both produce this signal.
Phase 2.3 Redis lock: SET NX EX per (agent, claude_uuid) with 5-min TTL
and Lua-script release. Async poll loop (250ms tick) so the event loop
stays free during contention. Cold turns skip the lock (no JSONL to
corrupt). Hard 30s wait ceiling — beyond that the contender gets HTTP
429 with retry hint. Mitigation for Anthropic #20992 (concurrent
--resume JSONL writes corrupt the file).
Per-user ownership at the row layer: even agent owners cannot read or
send into another user's session (E6 isolation in the design doc).
Returns 404 for ownership failures so we don't leak session-id existence.
Tests (tests/integration/test_session_turns.py, run inside
trinity-backend container with docker.sock mounted for testfix
recreation + JSONL surgery in Scenario C):
Scenario A: 3-turn happy path — same Claude UUID across turns
Scenario B: turn 2 recalls a secret from turn 1, no text-replay
Scenario C: JSONL deletion mid-session triggers fallback + recovery
Scenario D: concurrent POSTs serialise via Redis lock
(asserts finish_gap ≈ winner_work_time, NOT total wall)
Scenario E: switching sessions A → B → A preserves A's UUID
5 passed in 54.5s against the live agent-testfix container (recreated
onto the rebuilt base image first per L4 in the plan). Phase 1's 25
unit tests still pass — no regressions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(session-tab): frontend Session surface
Phase 3 of docs/planning/SESSION_TAB_2026-04.md. Adds the new "Session"
tab in AgentDetail, gated on the is_session_tab_enabled() platform flag
so it stays invisible until explicit opt-in (default off).
Backend prerequisite — routers/settings.py:
- GET /api/settings/feature-flags exposes a curated allowlist of UI-
relevant flags to any authed user. The existing /api/settings/{key}
endpoint is admin-only and would block non-admin frontends from even
knowing whether to render the Session tab. The new endpoint reads
through services.settings_service.is_session_tab_enabled() so the
resolution order (DB → env → False) stays in one place.
Frontend:
- src/frontend/src/stores/sessions.js — Pinia store wrapping the six
/api/agents/{name}/sessions* endpoints with per-agent state isolation
and the feature-flag cache. Optimistic user-message insert with
rollback on send failure.
- src/frontend/src/components/SessionPanel.vue — structural copy of
ChatPanel reusing ChatMessages + ChatInput + ModelSelector. Differs
from Chat in three places per the design doc:
* Sends bare user_message to POST .../sessions/{id}/message — no
buildContextPrompt text-replay (the agent already has working
memory via --resume).
* "Reset memory" button + confirm modal that clears the cached
Claude UUID without deleting the message log (Phase 3.4).
* Per-session selector subtitle: turn count, context % used,
cached-memory dot (emerald/gray), and consecutive_resume_failures
indicator (Phase 3.5).
Lean cut for first-visible-surface: voice mic, file upload, and SSE
dynamic status labels are deferred — those need backend extensions
(file payload on the turn endpoint, async_mode + SSE on the same).
- src/frontend/src/views/AgentDetail.vue — new Session tab inserted
between Chat and Dashboard/Schedules, gated on
sessionsStore.sessionTabEnabled. Layout sites that previously
branched on activeTab === 'chat' now use a shared isFullscreenTab
computed so Chat and Session both get the input-pinned-to-bottom flex
layout. ?tab=session deep-link allowlist updated.
- src/frontend/e2e/session-tab.spec.js — Phase 3.6 Playwright spec.
Marked @Interactive (not @smoke) because each run makes one real
Claude API call (~10–60s). Snapshots the prior flag value in
beforeAll, force-enables for the run, restores in afterAll so a
failed run doesn't leave the platform with the flag dirty. Three
cases:
* tab is hidden when flag is off
* tab appears, "+ New Session" → send turn → reply visible →
Reset memory modal opens + closes
* Chat tab still works after Session interaction; switching back
preserves Session state
Visually verified in the live dev server: tab renders in correct
position, header layout matches Chat's structure, empty state and
placeholder copy match the design doc, "Reset memory" only shown when
an active session exists, full-viewport flex layout pins input to
bottom.
Phase 1 + Phase 2 work behind this change is unchanged: 25 unit tests
+ 5 integration tests still green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(session-tab): hardening + observability — cleanup service, contamination gate, docs
Phase 4 of docs/planning/SESSION_TAB_2026-04.md. Closes the JSONL
disk-growth loop, validates the GA-blocking cross-session contamination
hypothesis empirically, and lands architecture.md / feature-flows
documentation so the surface is discoverable.
Phase 4.3 — cross-session contamination GA gate (the load-bearing one):
- tests/integration/test_session_cross_contamination.py exercises the
Anthropic #26964 hypothesis end-to-end. Plants a randomly-generated
secret token in session A with explicit "do not echo" framing, asks
session B (different UUID, same agent, same cwd) to recall the token.
Hard-fails if the exact token leaks; soft-fails on partial-prefix
recall (PURPLE-DRAGON without the random suffix would only be
knowable from A's JSONL, not from training).
- PASSED in 9.5s on the current Claude Code version → shared-cwd model
is safe → Phase 5 rollout unblocked. Test stays in the suite as the
per-version regression guard.
Phase 4.2 — JSONL cleanup service:
- services/session_cleanup_service.py runs a 6h periodic sweep that
diffs every running agent's
~/.claude/projects/-home-developer/<uuid>.jsonl set against
db.list_active_claude_session_ids(agent) and reaps orphans whose
mtime is older than the 1h race guard. Race guard prevents the
cold-turn-vs-cleanup window where a brand-new JSONL exists on disk
before the backend has updated cached_claude_session_id.
- Same service exposes a synchronous reap_jsonl(agent, uuid) helper
called best-effort from routers/sessions.py reset/delete handlers so
the user-perceived disk-reclaim latency is sub-second. Never raises;
failures are logged and the periodic sweep is the safety net.
- Implementation uses execute_command_in_container — the same primitive
git_service / ssh_service / scheduler pre-check / agent terminal use.
No new agent-server endpoint, no base-image rebuild.
- New db.list_active_claude_session_ids(agent) facade method backed by
SessionOperations.list_active_claude_session_ids querying every
agent_sessions row whose cached_claude_session_id is non-null for the
agent.
- main.py wires startup (staggered +7.5s after cleanup_service to
offset Docker hits) and clean shutdown.
- tests/integration/test_session_cleanup.py: reset reaps synchronously,
delete reaps synchronously, periodic sweep keeps the active JSONL,
reaps an aged orphan, respects the 1h race guard for fresh orphans.
Phase 4.4 — architecture.md updates:
- Background Services table gets a Session Cleanup row.
- New "Session Tab" subsection in API Endpoints documenting all six
/api/agents/{name}/sessions* routes including the per-user ownership
rule (404 not 403) and the resume-failure fallback / Redis lock.
- New /api/settings/feature-flags row.
- New agent_sessions / agent_session_messages DDL block in Database
Schema, with the three Session-specific fields called out
(cached_claude_session_id, consecutive_resume_failures,
cache_read_tokens, claude_session_id audit).
Phase 4.5 — feature-flows/session-tab.md vertical slice:
- Full path from UI → API → DB → Side Effects with the JSONL lifecycle
table, the spike-pitfall defense map (L1/L2/L3/#20992/#26964), the
error-handling matrix, and the complete test catalog with the docker
run command for the integration suite.
- feature-flows.md index updated (Recent Updates row + Chat & Sessions
section entry).
Test totals: 25 unit + 9 integration = 34 tests, all green. Phase 4.3
serves as both the GA gate and the per-Claude-version regression guard.
Phase 4.1 (cache_read_tokens UI surfacing) deferred — the column is
already populated by the Phase 2 turn endpoint; surfacing is a minor
observability follow-up that doesn't block Phase 5.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(session-tab): tag Session-tab turns with triggered_by="session" and a gold badge
Previously Session-tab turns went into schedule_executions with
triggered_by="chat", so the Tasks tab couldn't tell them apart from
the Chat tab. The user-visible signal was that every Session turn
showed up under the sky-blue "chat" badge.
Backend (routers/sessions.py): both call sites that invoke
task_execution_service.execute_task — the cold/resume turn and the
resume-failure fallback retry — now pass triggered_by="session".
Existing rows are unchanged; the cutover is per-write.
Frontend (TasksPanel.vue): adds a "Session" option between "Chat" and
"Manual" in the trigger filter dropdown, plus an amber/gold badge
branch (bg-amber-100 dark:bg-amber-900/30 text-amber-700
dark:text-amber-300) — visually distinct from "paid" (bright yellow)
and from the sky-blue "chat" badge.
triggered_by is a free-form TEXT column (no enum constraint at the DB
or service layer), so adding "session" as a new value doesn't require
any migration or downstream consumer updates. Filter, badge, audit
log, activity stream, and dashboards all just see another value and
display it; nothing has to know about it explicitly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(session-tab): correct context-window accounting + raise frontend turn timeout
Five interrelated fixes from manual testing — all about the per-turn
"context %" metric being misleading and the browser timing out before
long-running session turns finished.
1) Agent server (docker/base-image/agent_server/services/claude_code.py)
process_stream_line's `result` event handler used to overwrite
metadata.input_tokens, cache_read_tokens, and cache_creation_tokens
with the values from result.usage. Those values are CUMULATIVE
across every internal API call the turn made (Claude Code packs
tool-use loops into a single user turn that maps to N internal API
calls). For an 18-iteration turn each reading the same 70K cached
prefix, result.usage.cache_read_input_tokens = 18 * 70K = 1.26M
tokens — billing-cumulative, not the prompt size of any single call.
Overwriting per-message values with that aggregate made our
context-window-pressure metric grow far beyond the 200K limit even
when no individual API call was anywhere close to the wall.
Fix: result handler now only extracts model-level facts (cost,
duration, num_turns, session_id, error info, modelUsage.contextWindow).
Per-API-call usage stays in the per-assistant-message handler, where
the LATEST message's values represent the FINAL API call's prompt
size — exactly what determines whether the next turn will fit.
Also added a per-message usage-extraction block to the assistant
branch of process_stream_line (it previously had no usage extraction
at all, relying entirely on the result handler — which made my
first attempt at this fix produce zero values). parse_stream_json_output
already had the equivalent block (lines 211-215).
Base image rebuilt; agent-testfix recreated onto the new image
(image sha 0a1e20b40da1).
2) Backend (services/task_execution_service.py)
Replaced `context_used = metadata.input_tokens` with
`cache_read + cache_creation` (with input_tokens fallback when
caching isn't engaged). input_tokens is sometimes the disjoint
fresh value and sometimes inflated by the agent server's
modelUsage.inputTokens override on tool-call turns. cache_read
and cache_creation come straight from Anthropic's usage object
and (post agent-server fix) are reliable per-call values that
monotonically reflect the cached conversation prefix.
3) DB (db/sessions.py)
total_context_used is now a HIGH-WATERMARK (MAX of prior + new),
not the latest value. Per-turn context naturally oscillates by ~2x
between text-only and tool-call turns; the watermark gives users
a stable monotonic upper bound on session pressure that only goes
up.
Capped the watermark at total_context_max as a safety belt against
any future agent-server bug that emits cumulative-billing token
counts. Genuine per-call peaks should never exceed the model's
context window — if they do, that's an accounting error not a
real overflow, and the UI should display 100% rather than 648%.
4) Frontend (stores/sessions.js)
Bumped the Axios timeout on the session turn endpoint from 305s
(~5 min) to 7260s (= TIMEOUT-001 cap of 7200s + 60s slack). The
session turn endpoint is synchronous and may legitimately run for
the agent's full execution timeout. With the previous 305s ceiling
the browser threw a misleading "failed" toast on tool-heavy turns
that ran longer; the response still landed in the DB and the UI
recovered after a page refresh, but the user saw a phantom error.
Verified end-to-end with a 6-turn mixed sequence (text + tool-call):
per-call cache_read now reports ~11636 on text-only turns and ~18000
on tool-call turns (one extra round-trip's worth) instead of the
previous bogus 1,257,915 on tool-heavy turns. Watermark grows from
18073 to 18429 across 6 turns — monotonic, no oscillation, real
per-call peaks.
Existing inflated session rows (the bogus 648% / 100% sessions from
before this fix) stay as-is — the watermark cap stops them growing
further but the historical max is permanently stored. New sessions
created after this commit are accurate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(session-tab): Phase 5.1 — context warnings, stdout-race recovery, limitations doc
Bundles the round-of-decisions outcomes from the Phase 5 open-questions
discussion. Three code changes, three deferrals documented, one item
gated on a manual test before shipping.
Frontend (SessionPanel.vue) — context-window pressure warnings.
Buckets driven by the active session row's watermark divided by
total_context_max:
< 75% : nothing
75–89% : subtle gray "Session context: X% — heavy" hint
90–99% : amber banner with "Reset memory" suggestion
≥ 100% : red banner warning the next turn may fail or trigger
memory-loss fallback
No hard send-block at 100% — fallback path stays as the safety net.
Computed values gracefully handle a brand-new session (no row yet).
Agent server (claude_code.py) — stdout pipe race soft recovery. When
a child subprocess inherits Claude Code's stdout, the final result
event line can be lost ("I/O operation on closed file" + "Reader
thread(s) stuck after process exit"). Previously surfaced as 502 with
the misleading "infrastructure failure; retry the task" message even
though the assistant had completed its work and accumulated text into
response_parts. The reply was sitting in the JSONL on disk untouched;
only the closing-stats line was lost in the pipe.
Fix: at the empty-result classification site, if response_parts has
accumulated assistant text, log a warning and fall through to the
success path instead of raising. cost_usd / duration_ms stay None
for these recovered turns (we don't know what they were); the backend
records the execution as success with null cost rather than a
misleading FAILED. Hard failure path stays for the truly empty case
(no response_parts content).
Base image rebuilt; agent-testfix recreated onto image
305731fc34e0. The user's last "comprehensive report" turn that
produced 67KB of content but threw a phantom error in the UI would
now succeed cleanly.
Docs (feature-flows/session-tab.md) — Known limitations section. Five
entries that need to make it into the user-facing docs at Phase 5.3:
1. Voice mic not wired into Session (deferred — Chat tab for voice)
2. Per-message file upload not yet supported on Session (Phase 5.2)
3. Agent restore from backup may require fresh sessions (separate
platform-level issue — workspace volumes not in backup script)
4. Long Session turns may surface phantom errors in browsers (Axios
7260s ceiling vs. browser/OS sleep — refresh recovers)
5. Stdout pipe race recovery is best-effort (recovered turns will
show null cost / duration in Tasks tab)
Round-of-decisions outcomes:
✅ #1 — UI thresholds shipped here, stdout race fixed here
⏳ #2 — subscription-change cache clear (E7) — gated on manual
§5.5 test before shipping the proactive clear
⏭ #3 — backup/restore (E9) — separate platform-level issue
later; documented in §Known limitations
⏭ #4 — admin JSONL UI access — deferred follow-up
📋 #5 — file upload parity — Phase 5.2
⏭ #6 — voice on Session — deferred + documented limitation
The §5.5 E7 verification test was added to tests/manual/session-tab/README.md
which is tracked locally only (per the testing-kit precedent). Decision
on the proactive cache-clear hinges on its outcome.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(session-tab): Phase 5.2 — file-upload parity with Chat
The Session tab's input had drag-drop and the paperclip button (inherited
from ChatInput), but the second arg from ChatInput's submit event
(the files array) was being parameter-discarded in SessionPanel.vue
and never reached the backend. Result: users uploaded a file, sent
their message, and the agent reported "I can't see any attached file
in this conversation" — the upload silently dropped.
Closes the parity gap by mirroring routers/chat.py's exact upload
handling. No new behavior — same limits (3 files, 5 MB each, 5 MB per
image, 20 MB total image budget per WEB_MAX_*), same image-vs-non-image
split, same prompt-line append.
Backend (routers/sessions.py)
- SessionMessageRequest: new optional `files` list (same shape as
ParallelTaskRequest.files / WebFileUpload — name, mimetype, size,
data_base64).
- Turn endpoint: when body.files is set, decode each via
services.upload_service.decode_web_file, then run the same
process_file_uploads() helper Chat uses. Non-images get written
into the agent workspace via Docker put_archive; images come back
as already-decoded vision-block dicts in image_data.
- Append the file_descs lines to a new `effective_message` so the
agent prompt contains "[File uploaded by X]: name (size) saved to
path" references inline. Persisted user message stays as the
original body.message — visible chat log reads naturally; the
agent sees the file references.
- Pass image_data to execute_task as `images=` so vision blocks land
on the next API call. Both the resume call and the cold-retry
fallback receive the same effective_message + images (files were
already written to the workspace before the first attempt; cold
retry just re-references them).
- 502 from process_file_uploads' all_writes_failed bubbles up
cleanly; agent-not-found pre-check returns 503.
Frontend (stores/sessions.js)
- sendMessage() accepts a `files` array in opts; included in the
POST body when non-empty. Optimistic user-message insert stays
text-only (the chat log preview doesn't need to render
file chips).
Frontend (SessionPanel.vue)
- onSubmit() now takes both args from ChatInput's submit event.
Allow-empty-text-with-files is permitted (matches Chat's UX).
- Forwards files into sessionsStore.sendMessage's opts.
Verified end-to-end on a fresh session: uploaded a 66-byte text file
with a unique sentinel embedded; agent read the file out of the
workspace and recalled the sentinel verbatim. No fallback fired.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(session-tab): drop watermark, store last-cache reading directly
agent_sessions.total_context_used was a high-water mark (MAX of all
per-turn cache readings, capped at total_context_max). Claude Code's
auto-compact (~85% of the model window) silently resets the cache
mid-turn, so the watermark asymptoted near the compact threshold and
stopped conveying useful information — every heavy session ended up
visually stuck around 67-69% regardless of actual recent activity.
Switch to direct assignment of the most recent assistant turn's cache
size. The total_context_max cap (cc5c37b) stays as defence-in-depth
against agent-server accounting bugs reporting impossible token
counts. Existing inflated rows on agent-testfix self-heal on next
turn — no migration, no backfill.
Two new unit tests pin the contract: a lower new value overwrites a
higher prior value, and a None reading preserves the prior value
(stdout-pipe-race recovered turns).
* fix(session-tab): rename "% context" to "% last cache" and remove pressure banners
The 75/90/100% banners never fired empirically — Claude Code auto-
compacts mid-turn at ~85% of the model window, which prevents the
underlying metric from ever crossing 75%. A pressure warning that
silently never warns is worse than none.
The subtitle label "% context" reads as session memory pressure when
it's actually the most recent assistant turn's cache size. Relabel to
"% last cache" so the meaning matches what the column actually stores
(after the prior commit dropped the watermark semantics).
Reset modal copy: drop the "context-window pressure" framing for the
same reason, replace with "start a clean line of thought."
Removes orphaned contextPct / contextPctBucket / currentSession
script helpers — no remaining users after the banner deletion.
* docs(user-docs): add Session tab user guide
Covers what the Session tab is, when to use it vs Chat, and the three
limits/behaviours users will hit on a long heavy session:
- The "last cache" metric — explicitly per-turn cache size, NOT
session memory pressure. Bounces because of auto-compact.
- Auto-compact at ~85% of the model window — what users see (sudden
drop in % last cache, ~2 min added latency), what survives (working
memory in compressed form), what doesn't (verbatim history).
- The 50-turn agentic-loop cap (max_turns_task) — per-turn iteration
budget, NOT session message count. Heavy 12-step tasks routinely
hit it. Includes a curl recipe for raising the cap per agent via
PUT /api/agents/{name}/guardrails.
Also documents Reset memory, file attachments, the Session API
endpoint surface, and the four known limitations carried over from
docs/memory/feature-flows/session-tab.md (voice not wired, DR
backup forces fresh sessions, suspended-browser phantom errors,
stdout-pipe-race best-effort recovery).
Modeled after docs/user-docs/agents/agent-chat.md for tone and
structure.
* feat(db): persist auto-compact events on session messages and executions
Claude Code's auto-compact (~85% of the model window) silently summarizes
~170k tokens of conversation into a ~10k summary mid-turn, takes ~2 min,
and previously left no trail in our data — users saw only an unexplained
long execution and a sudden % drop on the next turn.
This change adds three additive nullable columns plus the persistence
plumbing across the standard Trinity router → service → db pipeline:
- agent_session_messages.compact_metadata (TEXT, JSON list of events)
- agent_sessions.compact_count (INTEGER, running tally)
- schedule_executions.compact_metadata (TEXT, denormalized for Tasks)
The session-level compact_count drives a future inline "consider starting
fresh" hint without scanning per-message rows. The denormalized copy on
schedule_executions lets the Tasks tab render the badge without joining
session messages — Trinity invariant 1 (router → service → db) preserved
on both the Session router and the task_execution_service.
Migration is idempotent via the existing _safe_add_column helper. Empty
columns on existing rows; populates from the next turn forward once the
agent server is rebuilt with the parser branch (separate commit).
One new unit test pins compact_metadata persistence + compact_count
accumulation across turns; the existing test_session_operations DDL
fixture mirrors the new columns.
* feat(agent-server): parse compact_boundary events and emit structured log
Claude Code emits {"type":"system","subtype":"compact_boundary","compactMetadata":{trigger,preTokens,postTokens,durationMs}}
on its stream-json output when it auto-compacts mid-turn. Both parsers
(parse_stream_json_output for batch, process_stream_line for live) now
recognise the event, append a CompactEvent to metadata.compact_events,
and emit a structured INFO log line per event:
event=session_auto_compact claude_session_id=... trigger=auto
pre_tokens=170325 post_tokens=12691 duration_ms=110361
Vector picks the line up via Docker stdout — no infrastructure change.
The compact_events list rides through ExecutionMetadata.model_dump()
into the agent server HTTP response, where the backend extracts and
persists it (separate commit).
Five new unit tests exercise: single-event capture in batch parser,
multi-event ordering within one turn, empty-events on a normal turn,
streaming-parser capture, and model_dump round-trip for the wire shape.
Live behaviour requires rebuilding trinity-agent-base:latest and
recreating each agent container — already done locally at the new SHA.
* feat(session-tab): surface auto-compact events in Session + Tasks UI
Two surfaces, one signal:
TasksPanel — adds a small violet "compacted" badge next to the trigger
badge for any execution that fired one or more compact_boundary events.
Multi-compact turns show `compacted ×N`. Hover tooltip lists each event
with pre→post tokens and duration so latency anomalies become legible
at a glance. Reads task.compact_metadata (denormalized JSON column)
without needing to follow the relation back to agent_session_messages.
SessionPanel — adds an inline italic hint adjacent to the Reset memory
button when the active session has compacted more than 5 times. Reads
session.compact_count (running tally maintained by the DB layer). Quiet
under normal use; visible only when stacked compacts have meaningfully
degraded summary fidelity. Threshold lives as a script-local constant
(COMPACT_HINT_THRESHOLD) for easy tuning from telemetry later.
The currentSession computed (deleted in Bundle A when the pressure
banners went away) is reinstated here since the inline hint needs it.
Pinia store sessions.js needs no change — compact_count and
compact_metadata ride through on the existing API responses without
new state slots.
* fix(db): propagate compact_metadata through DatabaseManager facade
b12307c extended ScheduleOperations.update_execution_status with the
new compact_metadata kwarg but missed the thin facade wrapper in
database.py. The session turn endpoint hit:
TypeError: DatabaseManager.update_execution_status() got an
unexpected keyword argument 'compact_metadata'
Trivial fix — the facade just forwards. Caught at runtime on the first
Session turn after Bundle B landed.
* fix(db): propagate compact_metadata through DatabaseManager.add_session_message
Same facade gap as the prior fix for update_execution_status — the thin
wrapper in database.py was missed. Surfaced as:
TypeError: DatabaseManager.add_session_message() got an unexpected
keyword argument 'compact_metadata'
…on the first Session-tab turn after the previous facade fix unblocked
the schedule_executions write path.
Adds the same two pass-through kwargs (compact_metadata,
compact_event_count) so the Session router can persist auto-compact
events on session messages and bump the running compact_count tally.
* fix(agent-server): recover from stdout pipe race via JSONL fallback
When a tool subprocess (or MCP grandchild) inherits Claude Code's
stdout fd and wedges the agent server's reader thread, the stream-json
result event is lost. The Phase 5.1 soft-recovery (response_parts !=
[] → synthesize success) only fires when stdout managed to deliver at
least one assistant text block before the wedge. For races that fire
mid-tool-call (zero text emitted to stdout), response_parts is empty
and the soft-recovery falls through to a hard 502.
Symptom users saw: heavy multi-step tasks (e.g. /session-context-pressure
running `python3 -c "..."` via Bash to generate 32KB of synthetic text)
sometimes returned "Execution completed without a result message after
0 tool calls / 1 turns (raw_messages=4)" — even though the JSONL on
disk contained the fully-completed turn. Probabilistic; retry usually
worked; root cause was the kernel pipe buffer exhausting before the
reader thread could drain after grandchildren died.
Fix: when the existing soft-recovery path falls through (no
response_parts text), read the session's JSONL via a side-channel that
is INDEPENDENT of stdout (Claude Code's own session record). Walk
backward to the most recent user-input boundary (string content,
distinguished from tool_results which have list-of-dicts content),
collect every assistant.text block emitted after, and synthesize a
soft-success response. Sets metadata.recovered_from_jsonl=True for
observability. If no text was emitted between boundary and EOF, the
turn is genuinely incomplete and the original 502 surfaces.
11 unit tests cover happy path (matches the actual failure shape with
2× Bash tool_use + tool_result + final text), boundary discrimination
(tool_result must not terminate the user-input search), multi-turn
isolation (only the LAST turn's text recovers), genuine-incomplete
detection (thinking-only or tool-only turns return None and surface
the 502), and robustness (malformed/truncated tail lines, blank lines).
Bounded read at 10MB to defend against pathological JSONL sizes; uses
existing /home/developer/.claude/projects/-home-developer/ path
already known to the cleanup service.
Verified end-to-end on the rebuilt agent-base: 7 turns of
/session-context-pressure executed cleanly with 3 reader-thread races
that drained naturally (none required the new fallback). Recovery is
a safety net only; happy-path behavior unchanged.
Backend reload not required. Agent-base rebuild + container recreate
required to deploy. Backward compat: old images returning no
recovered_from_jsonl field default to False.
* fix(agent-server): pull compact event detail from JSONL after the turn
Bundle B's stdout-side parser detected compact_boundary events but
captured them with all-None pre/post/duration/trigger fields — Claude
Code's --output-format stream-json strips the compactMetadata envelope
on the way out (the JSONL on disk has it, stdout doesn't). Confirmed
on the live agent: stored compact_metadata blobs were
[{"trigger":null,"pre_tokens":null,"post_tokens":null,...}] while the
JSONL contained the canonical shape with real numbers.
Add _extract_compact_events_from_jsonl(session_id, since_iso=None) — a
short helper modeled after the recovery extractor — that scans the
session JSONL post-turn and returns CompactEvent records with the
real detail fields. Called in execute_headless_task right before
returning, scoped via since_iso to records emitted from a turn-start
anchor onward (the JSONL accumulates across the resumed session, so
unfiltered would include compacts from prior turns).
Detection-only stdout branch is retained as a safety net (count
preserved if JSONL read fails) but its log line is removed — the
authoritative log line now fires from the JSONL extract with full
fields.
Effect on existing data: stored compact_metadata blobs from before
this commit keep their nulls (no backfill); new turns from now on
land with full pre_tokens / post_tokens / duration_ms / trigger /
timestamp. Tasks-tab tooltip on the violet "compacted" badge now
shows real numbers.
9 new unit tests (20 total in the suite): canonical JSONL shape,
since_iso scoping with exclusive and inclusive boundaries, multi-
compact ordering, missing/null compactMetadata defensive handling,
malformed-line robustness, empty-result paths.
Live smoke verified: rebuilt base image, recreated testfix, turn
endpoint returns compact_events:[] for non-compacting turns; the
existing session a2trktlTnXA9KD- already shows compact_count=1
unchanged.
Requires agent-base rebuild + container recreate to deploy.
* fix(credentials): strip auto-injected mcpServers.trinity before validating
Commit b474520 (sec(mcp): structure-validate .mcp.json content) added
RESERVED_SERVER_NAMES = {"trinity"} as a defense against attacker-
controlled redefinition of the auto-injected Trinity MCP server entry.
But the agent server's inject_trinity_mcp_if_configured() writes
mcpServers.trinity into .mcp.json on every agent start where
TRINITY_MCP_API_KEY is set — so the file the user loads in the
credentials editor always contains it, and every save trips the
validator with "MCP server name 'trinity' is reserved by Trinity".
Effect on the user: the credentials Save button silently failed for
every agent that had been started at least once. Confirmed regression
introduced by b474520 — pre-this-branch, .mcp.json went through the
inject endpoint with no content validation and the same auto-injected
trinity entry was accepted.
Fix at the backend: strip mcpServers.trinity from the submitted JSON
before it reaches validate_mcp_config. The agent re-injects the
canonical trinity entry from env vars on next startup, so the user
can't lose it by leaving it out of the saved file. The defense-in-
depth value of the reserved-name rule is preserved — an attacker still
can't substitute a different shape under the trinity name, because
their substitution is dropped before validation rather than rejected.
Side benefit: this self-heals corrupted bearer values in the existing
trinity entry (e.g. the historical "Bearer sqlite3.IntegrityError: ..."
strings written by some long-removed prior code path). The agent
overwrites the entry on next start with a fresh value from env.
11 unit tests cover happy path, mixed user+trinity entries, the exact
historical corruption shape, no-op cases (no trinity / no servers /
no mcpServers), robustness on malformed input (passes through to the
validator's own JSON error), and output-shape preservation.
Verified end-to-end on testfix: POST /api/agents/testfix/credentials/inject
with the corrupted-bearer trinity entry + a context7 entry returns
HTTP 200, backend logs the strip, on-disk .mcp.json contains only
context7 with the corrupted bearer cleanly removed.
Backend reload only — no agent rebuild required.
* fix(credentials): allow canonical trinity entry through validator instead of stripping
Replaces the strip approach from 705de47 (Option B) with a shape-
equivalence check in the validator (Option A) — the strip silently
dropped the user's edits to the trinity entry, which broke the
legitimate "rotate my MCP API key" flow on agents that don't have the
auto-inject env var set to recover the entry on next start.
Symptom 705de47 introduced: opening .mcp.json, editing one digit in
the trinity Bearer token, clicking Save → backend stripped the entry
before validation → on-disk file became {"mcpServers": {}} → user's
trinity MCP entry was lost.
Fix: keep RESERVED_SERVER_NAMES = {"trinity"} as the closed-shape
defense, but special-case it in `_validate_entry`: if the entry under
the trinity name matches the canonical Trinity-MCP shape exactly
(only `type`, `url`, `headers` keys; `type=http`; `url` matches the
configured TRINITY_MCP_URL or the documented default; `headers`
contains only `Authorization` with a `Bearer trinity_mcp_…` token),
accept it. Otherwise hit the existing reserved-name reject.
Strict allowlist on the canonical shape:
- exact key set: {type, url, headers} — no extras
- type == "http"
- url ∈ {TRINITY_MCP_URL env, "http://mcp-server:8080/mcp"}
- headers has only Authorization
- Authorization matches /^Bearer\s+trinity_mcp_[A-Za-z0-9_-]{1,200}$/
Attacker scenarios still rejected:
- stdio redefinition (npx + args) under trinity → reserved
- http with evil URL (https://evil.com/mcp) → reserved
- canonical url + extra header (X-Custom) → reserved
- canonical url + non-Bearer auth → reserved
- canonical url + Bearer with non-trinity_mcp_ token → reserved
Strip helper and its caller in routers/credentials.py reverted.
test_strip_reserved_trinity.py removed (its scenarios now covered by
the validator's own tests).
10 new unit tests in test_mcp_validator.py cover the canonical-shape
allowance + each rejection variant. 102 total tests in the validator
suite — all green.
Live verified end-to-end on testfix:
- POST /credentials/inject with edited bearer → 200, file updated
- POST /credentials/inject with stdio shape → 400 reserved-name
- User's original bearer restored after the test
* chore(gitignore): exclude saved-conversations/ and tests/manual/ from future stages
Both directories are local-only working artifacts (conversation
transcripts and the hand-driven testing kit) that should never be
pushed. They were already gitignored implicitly by being untracked,
but a future ``git add .`` could inadvertently stage them. Listing
them in .gitignore makes the exclusion explicit and accident-proof.
* feat(session-tab): flip feature flag default to True for GA (#651)
Phase 5.3 GA. Session tab is now exposed to users on every fresh
install without an admin opt-in step.
Resolution order is unchanged:
1. system_settings row 'session_tab_enabled' if present (admin override)
2. SESSION_TAB_ENABLED env var (only honored as opt-out: false/0/no)
3. Default: True
Admins who want to keep it hidden can set
``session_tab_enabled = false`` in system_settings or export
``SESSION_TAB_ENABLED=false``.
Closes the Phase 5.3 step in #651.
* docs: address PR #652 review feedback — requirements, GA status, security section
- Add §5.8 Session Tab entry to requirements.md (Rule 4 — new P1 capability
must be registered in the single source of truth)
- Flip three stale "default off / Phase 5 rollout pending" references in
architecture.md, feature-flows.md, and feature-flows/session-tab.md to
reflect the GA default-on flag flip from PR #651
- Add ## Security Considerations section to feature-flows/session-tab.md
covering 404-not-403 ownership isolation (E6), Redis lock for concurrent
--resume (Anthropic #20992), cross-session contamination empirical gate
(Anthropic #26964), and JSONL prompt-injection persistence with the
Reset memory mitigation
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…549) (#641) UnderDefense pentest 3.4.2 flagged Trinity as missing the standard security header set. Most production headers were already in place via nginx security-headers.conf but three surfaces needed work: 1. FastAPI middleware (src/backend/main.py): API responses (Swagger, direct curl, /docs) lacked X-Frame-Options, Cross-Origin-Opener-Policy, and conditional Strict-Transport-Security. Added all three. CSP is intentionally NOT set on /api/* responses — they're JSON, not rendered, and a strict CSP would block legitimate Swagger UI interactions. 2. Vite dev server (src/frontend/vite.config.js): `npm run dev` served the SPA with zero security headers, so devs only saw CSP regressions in production. New configureServer plugin mirrors security-headers.conf (with the dev-only relaxation of script-src 'unsafe-inline' 'unsafe-eval' for Vite HMR + module preloads). HSTS deliberately NOT emitted in dev — would force the dev box's hostname to HTTPS in the browser HSTS cache and break subsequent HTTP work. 3. Production nginx (src/frontend/security-headers.conf + nginx.conf): added Strict-Transport-Security gated on X-Forwarded-Proto from the upstream TLS terminator (cloudflare / external LB). Implemented via `map $http_x_forwarded_proto $hsts_header` so the conditional is evaluated once per request. nginx skips emission when the mapped value is empty (default branch). The FastAPI HSTS check honours both request.url.scheme and X-Forwarded-Proto so it works whether uvicorn is direct-facing or behind a proxy with --proxy-headers. X-Frame-Options uses DENY on FastAPI (API responses should never be iframed) and the existing SAMEORIGIN on the frontend (SPA could legitimately be embedded in same-origin contexts). Cross-Origin-Embedder-Policy intentionally still omitted across the board: require-corp would break any future cross-origin resources and no SharedArrayBuffer use case justifies the cost. Tests in tests/test_security_headers.py extended with assertions for X-Frame-Options, COOP, HSTS-absent-on-HTTP, and HSTS-present-on- forwarded-HTTPS. All 5 pass against a live local stack. Verified manually: curl -I against backend (:8000), Vite dev (:8001), and nginx -t syntax check on the production config inside the Trinity docker network. Closes #549. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…der (#630) (#639) Two defensive layers for the "Execution completed without a result message after 0 tool calls / N turns" failure mode reported in #630. Background: Claude Code emits a final {"type": "result", ...} stream-json line on clean exit. process_stream_line populates metadata.cost_usd / duration_ms / num_turns from it. If those fields stay None, _classify_empty_result records the execution as a 502 "completed without a result message" — even when Claude actually finished the work. Two paths cause that misclassification: 1. read_stdout in execute_headless_task wraps the per-line work in a try/except json.JSONDecodeError. Any other exception (e.g. registry.publish_log_entry blowing up, sanitize tripping on weird input) propagates out of the for loop, kills the reader thread, and silently drops every subsequent line — including the result line. 2. Even when raw_messages contains a fully parsed result entry, if process_stream_line never runs for that line (reader interrupted between the append and the call) metadata stays None. Fix: * read_stdout now isolates each line: any non-RuntimeError exception inside the per-line block is logged-and-skipped instead of unwinding the reader. RuntimeError (permission-mode failure) still propagates intentionally. Mirror the same hardening on the chat path's read_stdout. * New _recover_metadata_from_raw_messages scans raw_messages tail for a result entry and back-fills cost_usd / duration_ms / num_turns / token counters. _classify_empty_result calls recovery before classifying so the success path resumes whenever the result line was captured but not parsed. This addresses the most common failure mode identified by the issue (reader exit between append and parse). It does NOT solve the case where the result line itself is corrupted in transit (e.g. interleaved writes from inherited stdout fds in MCP children) — that requires changes to how MCP servers are launched and is tracked separately. Tests in tests/unit/test_claude_code_result_recovery.py cover: - Recovery is a no-op when metadata already populated, raw_messages empty, or no result entry present - Populates cost_usd / duration_ms / num_turns from result entry - Picks last result when multiple, ignores malformed result entries - modelUsage inputTokens wins over usage.input_tokens (mirrors process_stream_line) - _classify_empty_result short-circuits the 502 when recovery succeeds - Per-line reader isolation: publisher raising on every line still yields all raw_messages and recovery succeeds Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… (#648) * fix(webhooks): delegate webhook token methods on DatabaseManager (#647) WEBHOOK-001 (#291) added four methods to ScheduleOperations (generate_webhook_token, get_schedule_by_webhook_token, revoke_webhook_token, get_webhook_status) but never added the matching pass-throughs on the DatabaseManager facade. Because there is no __getattr__ proxy, every webhook endpoint blew up with AttributeError on a live stack. The regression went undetected because integration tests don't run in CI. Add the four pass-through methods (12 lines, no logic changes), plus an AST-based unit test that statically verifies every db.<method>(...) call in routers/ and services/ resolves to a real method on DatabaseManager. The new test is split into a strict regression check for the four WEBHOOK-001 methods and a broader scan of all db.* call sites guarded by a KNOWN_FACADE_GAPS allowlist for eight unrelated pre-existing gaps — those are real but out of scope for this patch and should be cleaned up in a follow-up issue. Closes #647 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(catalog): register test_database_facade_delegation for #647 Add the new lint-style facade-delegation test to tests/registry.json and .claude/agents/test-runner.md so future test-runner sweeps know it exists. Pure documentation/catalog — no test logic changes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…subscription agents (#612) (#663) Subscription-mode agents authenticate via CLAUDE_CODE_OAUTH_TOKEN env var (SUB-002) and have no .credentials.enc file. The agent-start path ran inject_assigned_credentials unconditionally, the encryption service raised ValueError("No .credentials.enc file found in agent workspace"), and the caller's "not found" substring check missed it (the actual message contains "file found", not "not found"). The misleading status leaks to the start endpoint response and prompts operators to re-assign subscriptions / recreate containers — disrupting running state for no reason. Two-part fix: 1. CredentialsFileNotFoundError (ValueError subclass) in services/credential_encryption.py. import_to_agent now raises this specific type when .credentials.enc is absent, so callers can catch it without fragile substring matching. Subclassing ValueError preserves the admin import endpoint's existing 400 behavior — that path *should* surface a real error when the operator explicitly asked for an import. 2. Subscription-mode short-circuit in inject_assigned_credentials. db.get_agent_subscription_id() check fires before the import path runs; returns {"status": "skipped", "reason": "subscription_mode", "detail": "..."} with explicit mention of CLAUDE_CODE_OAUTH_TOKEN so operators see this is by design. The substring-match path is removed entirely — the explicit exception subclass replaces it. Tests: tests/unit/test_inject_assigned_credentials.py — 8 cases: - subscription mode short-circuits without consulting the service - file-missing returns skipped (not failed) - CredentialsFileNotFoundError remains a ValueError (admin path compat) - happy path still returns success with file list - empty decrypted dict returns skipped - other ValueError shapes retry then surface as failed - transient ValueError recovers on retry - missing CREDENTIAL_ENCRYPTION_KEY returns skipped Combined with the existing #421 skip-inject tests: 32 tests pass, no regression. Issue: #612 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… in 24acbf1 Per-message file upload on the Session turn endpoint shipped in Phase 5.2 (commit 24acbf1): SessionMessageRequest.files is accepted and the turn handler runs the same process_file_uploads helper Chat does. Removes the stale Known Limitations row and clarifies the lean-cut paragraph. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…oop deadlock (#657) * docs: add workshops banner to top of README Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(agent-server): make drain_reader_threads async to prevent event-loop deadlock drain_reader_threads t.join(timeout=30) could block the asyncio event loop permanently when a CPU-heavy orphan MCP subprocess (setsid'd, escaped terminate_process_group) consumes 140-180% CPU and starves OS-level thread timeouts. All three t.join() calls and the orphan-killer wait are now run via asyncio.to_thread + asyncio.wait_for so deadlines are enforced at the event-loop level regardless of CPU pressure. The orphan-killer changes from _orphan_thread.join(timeout=10) to a daemon thread + threading.Event: event.wait(10) always returns within 10s, so asyncio.run()'s shutdown_default_executor() never blocks on a slow /proc scan. Sync callers in claude_code.py wrap with asyncio.run(). Fixes #657 Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
…v workflow Introduces docs/planning/TARGET_ARCHITECTURE.md — the optimal steady-state design Trinity should converge toward (PostgreSQL, actor model coordination, async-first agent communication, fleet observability, GuardAgent security). Updates CLAUDE.md, DEVELOPMENT_WORKFLOW.md, and the groom/roadmap/sprint playbooks to distinguish current architecture (what is built today) from target architecture (where decisions should point), and to use target architecture alignment as a ranking signal during backlog grooming and issue prioritization. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(security): Encrypt Slack bot tokens at rest (#453) The SLACK-001 public-link Slack integration (`db/slack.py`) was the last holdout still storing bot tokens as plaintext in SQLite, violating Architectural Invariant #12. Telegram (`telegram_bindings.bot_token_encrypted`), WhatsApp (`whatsapp_bindings.auth_token_encrypted`), and SLACK-002 (`slack_workspaces.bot_token`) all already encrypt via `services.credential_encryption` (AES-256-GCM, JSON envelope). This brings SLACK-001 in line with that pattern. Additionally, `slack_workspaces.bot_token` was rolled out with lazy encryption (encrypt-on-write + plaintext fallback at `slack_channels.py:47-49`) which left two sources of plaintext on disk: - Rows written before the encryption rollout - Rows copied from `slack_link_connections` by `_migrate_slack_channel_agents` A one-shot migration walks both tables on startup and re-encrypts any plaintext `xoxb-*` rows. Idempotent at row level (skip JSON envelopes) and at migration level (schema_migrations runner). src/backend/db/slack.py - Add `_get_encryption_service` / `_encrypt_token` / `_decrypt_token` (exact copy of the pattern in `db/slack_channels.py`, `db/telegram_channels.py`, `db/whatsapp_channels.py`) - Encrypt at `create_slack_connection` (write site) - Decrypt at `_row_to_connection` (read site, with `xoxb-*` plaintext fallback so runtime works pre-migration on legacy rows) - Caller-facing API surface unchanged — `slack_bot_token` field still contains plaintext in the returned dict src/backend/db/migrations.py - New `_migrate_slack_bot_token_encryption` registered in MIGRATIONS list - Walks BOTH `slack_link_connections.slack_bot_token` AND `slack_workspaces.bot_token`, encrypts plaintext rows in place - Hard-fail on missing CREDENTIAL_ENCRYPTION_KEY (matches the implicit pattern of every other consumer of CredentialEncryptionService) - Skips already-encrypted rows (signature: starts with `{` not `xoxb-`) - Defensive: skips silently if a table doesn't exist docs/memory/architecture.md - Reword Invariant #12 to acknowledge channel/subscription tokens as a documented exception (persisted but mandatorily encrypted), with the full list of tables under that rule - Add `slack_link_connections` DDL block; update `slack_workspaces` block to clarify the column-name vs content-type distinction tests/unit/test_slack_token_encryption.py (NEW, 14 tests) - TestRoundTrip: write encrypts, read decrypts, raw DB value is JSON envelope - TestPlaintextFallback: legacy `xoxb-*` row returns token + warning logged; corrupt envelope returns None + error logged - TestEncryptionHelpers: encrypt+decrypt isolation; encrypt raises ValueError on missing key; decrypt returns None on missing key - TestMigration: encrypts plaintext in both tables, skips encrypted, idempotent on second run, hard-fails without key, no-op on missing/empty tables Live verification on running backend: - Migration ran on startup: 1 row in each table re-encrypted (the real `ability.ai` workspace data) - On-disk now `{"version": 1, "algorithm": "AES-256-GCM", ...}` envelopes - `SlackOperations.get_slack_connection` returns the original `xoxb-...` plaintext via decrypt — caller-facing API surface unchanged Out of scope (filed separately): - Encryption tests for slack_channels.py + telegram_channels.py + whatsapp_channels.py (all shipped without dedicated test coverage): tracked in #664 - Renaming `bot_token` → `bot_token_encrypted` columns: cosmetic, would require a real schema migration; current naming works behind the service-layer encapsulation Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(req): annotate SLACK-001 bot token as encrypted at rest (#453) SLACK-002 (line 809) noted "bot_token encrypted"; SLACK-001 (line 781) didn't, even after #453 brought slack_link_connections.slack_bot_token under the same AES-256-GCM regime. Mirror the annotation for parity. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Pavlo <pash@pashs-MacBook-Pro.local> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
56 checks across 10 categories covering static file checks, YAML/JSON schema validation, security scanning, and AI-evaluable logical checks (skill coherence, CLAUDE.md quality, cross-file consistency). Serves as the canonical check list for the compatibility validation API and MCP tool. Closes-adjacent: #668 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…NULL status row (#669) (#676) `GET /api/monitoring/status` (the endpoint MCP `get_fleet_health` calls) was returning 500 whenever any `agent_health_checks` row had `status = NULL`. Root cause: the build loop did AgentHealthSummary(status=check.get("status", "unknown"), ...) `dict.get(key, default)` only returns the default when the key is *missing*. A row with the key present but value `None` returned `None`, which Pydantic v2 rejected because `AgentHealthSummary.status: str` is required. The sibling per-agent endpoint dodged this by triggering a fresh health check when no aggregate row existed, which is why `get_agent_health` worked while `get_fleet_health` 500'd in the production fleet that triggered #669. Fix: - Extract `_build_agent_summary(name, check)` and `_coerce_status(raw)` so NULL/missing/non-str status degrades to `"unknown"` consistently. Same for NULL `error_message` (would explode `.split("; ")`). - Wrap the aggregator in try/except returning a structured "unknown" payload rather than 500 (issue ask #1). Future schema drift surfaces as data, not as an outage. - Reconcile `docs/user-docs/operations/monitoring.md` — the listed `/api/monitoring/fleet-health` path doesn't exist; correct to `/api/monitoring/status`. Tests: 7 new unit tests in `tests/unit/test_fleet_status_resilience.py` covering NULL status, missing status key, missing check row, NULL error_message, non-string status, and sort-key tolerance. Does NOT fix the underlying scheduler stoppage (8 of 9 agents going months without a health-check refresh) — that root cause needs production logs and is split out as a separate ticket. Closes #669 (symptom) Refs #675 (scheduler stoppage follow-up) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… break it PR #943 added `git stash push -u -m "..."` before the dev pull. First deploy after merge (run 26454209415) failed: Saved working directory and index state On dev: auto-stash-deploy-... warning: failed to remove trinity-data/archives/: Permission denied warning: failed to remove trinity-data/avatars/...: Permission denied warning: failed to remove trinity-data/q.py: Permission denied No local changes to stash ← our `|| echo` fallback, MISLEADING ... error: Your local changes to the following files would be overwritten by merge: docker-compose.prod.yml The dev VM has Docker-volume directories (trinity-data/, archives/, avatars/) populated by containers running as root. The trinity user can't unlink those files. `git stash -u` tries to remove ALL untracked files as part of its working-tree-cleanup phase; the rm fails on the Docker-owned ones; stash aborts mid-cleanup, leaves the stash entry created but the tracked-file changes NOT reverted in the working tree; exits non-zero; our `|| echo` masks the failure as "no changes"; the subsequent `git pull --ff-only` aborts on the still-modified tracked file. Same symptom as the original bug, different root cause. Tracked-only stash (no `-u`) does what we actually want: capture the docker-compose.prod.yml modification, revert it in the working tree, let pull proceed. Untracked Docker-owned files are irrelevant to git pull — they don't block it and don't need to move. Stash entry from the failed run (`auto-stash-deploy-26454209415`) still exists on the dev VM and contains the docker-compose.prod.yml change. Inspect via `git stash list` + `git stash show -p stash@{N}`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
fix(ci): drop -u from git stash so Docker-owned untracked files don't break the deploy
… column reads Submodule moves to 9da562f. Skills (groom/roadmap/sprint/metrics/ read-docs) and DEVELOPMENT_WORKFLOW.md now derive Todo/In Progress/ In Dev/Done from status-* labels + GitHub open/closed state instead of the Project #6 Status column, which drifted in practice. Project board is still used for Rank/Tier/Epic/Theme. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds explicit issue numbers to each Key Open Question and a new Tracking Issues table mapping target-arch surfaces to GitHub issues created in this audit pass: #945 (postcard), #946 (Phase 2 experiment), #947 (GuardAgent), #948 (capability tokens), #949 (Celery decision). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…#910) * feat(enterprise): private submodule + EntitlementService seam + SSO PoC (#847 Phase 0) Issue #847 spike. Establishes the open-core split between the public Trinity backend and a private companion repo `Abilityai/trinity-enterprise` that ships compliance-gating features (SSO, SCIM, SIEM) without merging them into the public codebase. The spike research is in `docs/planning/OSS_ENTERPRISE_SPLIT_RESEARCH.md` (521 lines, six load-bearing decisions, stress-tested against eight other open issues). The condensed decision record is `docs/planning/ENTERPRISE_ARCHITECTURE.md`. What lands in this PR (Phase 0): Public-repo seam (the integration mechanism): * `src/backend/services/entitlement_service.py` — `EntitlementService` Phase 0 stub. Returns True for every `is_entitled(feature_id)` check unless `TRINITY_OSS_ONLY=1` env is set, in which case every check flips False. Module-level singleton + `_set_for_testing` test seam. * `src/backend/dependencies.py:requires_entitlement(feature_id)` — FastAPI dependency factory mirroring `require_role`. Raises HTTP 403 naming the missing feature so the UI can surface a "license required" toast. Lazy-imports the service so tests can swap singletons. * `src/backend/main.py` — conditional `try: from enterprise import register_enterprise except ImportError`. Loaders mounted under `/api/enterprise/*` when the submodule is present; OSS-only builds log an informational message and continue. Idempotent via `app.state.enterprise_registered`. * `src/backend/routers/settings.py` — `/api/settings/feature-flags` extended with `enterprise_features: list[str]`. Drives UI tab visibility. * `.gitmodules` + new submodule mount at `src/backend/enterprise` pointing to the private repo via SSH. * `docker-compose.yml` env pass-through for `TRINITY_OSS_ONLY`. * `.github/workflows/build-without-submodule.yml` — boots the backend without the enterprise submodule and asserts `/health` responds, `/api/settings/feature-flags` returns `enterprise_features: []`, `/api/enterprise/sso/providers` returns 404, and the OSS-only log line is emitted. Catches regressions where the conditional import becomes hard-required. Private-repo PoC content (in `Abilityai/trinity-enterprise`, mounted at `src/backend/enterprise/`): * `__init__.py` — `register_enterprise(app)` single entry point. * `sso/router.py` — `/api/enterprise/sso/{providers,login/{id}}` stubs. `GET /providers` returns the in-process registry (empty by default); `POST /login/{id}` returns 501 "PoC stub" or 404 for unknown id. Both gated by `requires_entitlement("sso")` with a lazy-import fallback so the private repo tests in isolation. * `sso/providers.py` — `SSOProvider` ABC + `StubProvider`. Tests (`tests/unit/test_847_entitlement_seam.py`, 14 cases): * EntitlementService default + TRINITY_OSS_ONLY deny path * Parametrised truthy/falsy env spellings * `requires_entitlement` allow/deny (skip-on-no-passlib for local) * `_set_for_testing` singleton swap * Static check that main.py uses conditional ImportError guard Docs: * `docs/planning/ENTERPRISE_ARCHITECTURE.md` — condensed decision * `docs/planning/OSS_ENTERPRISE_SPLIT_RESEARCH.md` — long-form research * `docs/dev/ENTERPRISE_LOCAL_DEV.md` — 15-min clone-to-running guide * `docs/memory/requirements.md` §34.1 Live verification (local instance): * Submodule mounted at `src/backend/enterprise/` * `GET /api/enterprise/sso/providers` → `[]` * `GET /api/settings/feature-flags` → `enterprise_features: ["sso","scim","siem"]` * `POST /api/enterprise/sso/login/foo` → HTTP 404 "Unknown SSO provider 'foo'" * With `TRINITY_OSS_ONLY=1`: `/api/enterprise/sso/providers` returns 403 "Enterprise feature 'sso' is not licensed", `enterprise_features: []` * Restored default: re-entitled Out of scope (separate follow-up issues): * Phase 1: Ed25519-signed license token + verify path + admin License UI * Phase 2: extract `audit_log` into the submodule as first real enterprise module * Phase 3: prove "core-primitive + enterprise-knob" pattern via #834 * Phase 4: real SSO/SAML implementation (replaces PoC stubs) * MCP entitlement edge for the TypeScript MCP server * Fix repo license-of-record (currently NOASSERTION) — owner decision Related to #847 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(enterprise): dual-mount submodule + frontend SSO view (#847 Phase 0.5) Add the frontend half of the enterprise seam. The private repo `Abilityai/trinity-enterprise` was restructured into `backend/` and `frontend/` subdirs, and the public repo now mounts it as TWO submodules at different paths — same URL, different mount points — so each consumer reads only its own subdir: src/backend/enterprise/ → backend/ consumed by Python (`main.py`) src/frontend/src/enterprise/ → frontend/ consumed by Vite (`main.js`) One private codebase to version; two clean import surfaces. Disk waste is ~2× the repo size (the same code cloned twice) which is far cheaper than two private repos drifting out of sync. Changes: Backend (import path bump): * `src/backend/main.py` — `from enterprise.backend import register_enterprise` (was `from enterprise import ...`). * `tests/unit/test_847_entitlement_seam.py` — static-check asserts the new import path. Frontend (new): * `src/frontend/src/main.js` — conditional `import.meta.glob('./enterprise/frontend/index.js', { eager: false })`. Empty in OSS-only builds; calls `mod.registerEnterprise(router, app)` when present. Logs which mode it's in. * `src/frontend/src/stores/enterprise.js` — new Pinia store. Loads `/api/settings/feature-flags` after auth, caches `enterprise_features: list[str]`. Getters: `isEntitled(featureId)`, `hasAnyEnterprise`. Test seam `_setFeaturesForTest`. * `src/frontend/src/components/NavBar.vue` — new `Enterprise` link `v-if="enterpriseStore.isEntitled('sso')"` with `PRO` badge. Hidden in OSS-only mode and when `TRINITY_OSS_ONLY=1`. Submodule: * `.gitmodules` — second entry at `src/frontend/src/enterprise/` for the same private repo URL. CI: * `.github/workflows/build-without-submodule.yml` — asserts BOTH mount points are empty (`backend/__init__.py` AND `frontend/index.js`) and that the OSS-only log line + 404 + empty `enterprise_features` invariants hold. Docs: * `docs/planning/ENTERPRISE_ARCHITECTURE.md` — directory tree + why one repo + dual-mount config + frontend seam description. * `docs/dev/ENTERPRISE_LOCAL_DEV.md` — three-submodule table, "working on the enterprise repo" steps for dual-mount sync. * `docs/memory/requirements.md` §34.1 — frontend integration in Key Features + private-repo layout. Private repo content (Abilityai/trinity-enterprise) restructured in a sibling commit (`refactor: split backend/ and frontend/ subdirs`): * `backend/__init__.py` + `backend/sso/` — was at repo root. * `frontend/index.js` — `registerEnterprise(router, app)`. Adds the `/enterprise/sso` route pointing at `EnterpriseSSO.vue`. * `frontend/views/EnterpriseSSO.vue` — Vue component. Fetches `/api/enterprise/sso/providers` via the public repo's shared `api.js`. Empty-state UI with the issue link. Live verification (local instance): * `/api/enterprise/sso/providers` → `[]` (backend still works after import path change). * Vite serves `/src/enterprise/frontend/index.js` (200) and `/src/enterprise/frontend/views/EnterpriseSSO.vue` (200) with the `api.js` import resolved to `/src/api.js`. * `import.meta.glob` returns a populated object → enterprise module loaded → `[enterprise] frontend module loaded` console log. Tests: 12 passed + 2 skipped (no-passlib local). No new test surface for the frontend store/component — playwright e2e would catch regressions but is out of scope for this Phase 0.5. Related to #847 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(enterprise): frontend in OSS, EntitlementService registry (#847) Reframe the open-core boundary: enterprise FRONTEND lives in the public OSS bundle and is gated server-side via the `enterprise_features` feature-flag. Only the private backend stays behind the submodule. Vue components have no algorithmic IP — the moat is the private backend logic (license verify, SAML signature checks, OAuth flows). This collapses the previous "dual-mount" complexity (same private repo at two mount points) back to a single submodule mount, and matches the existing feature-flag pattern (`session_tab_enabled`, `voice_available`). Trade-off accepted: enterprise Vue source is readable by anyone with the public repo, but the static UI files are not the load-bearing IP. The new closure mechanism is the EntitlementService **registry**: - Each enterprise backend module calls `entitlement_service.register_module(feature_id)` on boot. - `list_entitled_features()` returns the registered set (sorted). - OSS-only builds never call `register_module` → empty set → `enterprise_features: []` → OSS frontend hides every enterprise surface. - `TRINITY_OSS_ONLY=1` is a hard override (denies even when modules ARE registered). Closes three Phase-0 issues: 1. OSS users no longer see broken "Enterprise" nav — registry is empty without the submodule, feature-flag empty, NavBar hides. 2. Adding non-SSO features (SCIM, SIEM) is now additive: ship Vue file in OSS, add backend module, call `register_module(id)`. 3. Login-page SSO button extension point is straightforward — same pattern: OSS Login.vue reads the providers list from a backend API when entitled (out of scope for this PR, but the seam exists). Changes: Public repo: * `src/backend/services/entitlement_service.py` — replaces hardcoded `["sso","scim","siem"]` with a registry. `register_module(id)`, `is_entitled(id)`, `list_entitled_features()` all read from the set. Idempotent registration. TRINITY_OSS_ONLY=1 still hard- overrides everything. * `src/frontend/src/views/enterprise/SSO.vue` (new) — Vue PoC view, moved from the private repo's `frontend/views/EnterpriseSSO.vue`. Same UI; api.js import path adjusted to the OSS location (`../../api`). * `src/frontend/src/router/index.js` — static route entry for `/enterprise/sso` with `meta.requiresEntitlement: 'sso'`. `beforeEach` guard checks the entitlement store before navigation; redirects to `/` when not entitled (defence-in-depth against direct URL visits). * `src/frontend/src/main.js` — drops the `import.meta.glob('./enterprise/frontend/...')` block. Routes are static now. * `.gitmodules` — removes the `src/frontend/src/enterprise/` submodule entry. Only `src/backend/enterprise/` remains. * `src/frontend/src/enterprise` (submodule pointer) — deleted. * CI workflow `build-without-submodule.yml` — checks only the single backend mount; OSS-only frontend Vue files still bundle. * Docs (architecture, local-dev, requirements §34.1) — updated structure diagram, rationale, mount layout. * Tests (`test_847_entitlement_seam.py`) — registry contract: empty-by-default denies, `register_module` enables, idempotent. Updated allow-path test to `register_module("sso")` before asserting. Private repo (sibling commit, not in this PR): * `frontend/` subdir removed (moved to OSS). * `backend/__init__.py` now calls `entitlement_service.register_module("sso")` after mounting the SSO router. Live verification (local): * Default mode: `GET /api/settings/feature-flags` → `enterprise_features: ["sso"]` (only registered modules). `GET /api/enterprise/sso/providers` → `[]`. Enterprise nav link visible. * `TRINITY_OSS_ONLY=1`: `enterprise_features: []`, `GET /api/enterprise/sso/providers` → 403, nav link hidden. * Restored default — re-entitled. Tests: 13 passed + 2 skipped (no-passlib local). Related to #847 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(enterprise): SSO admin UI mock + Login page provider buttons (#847) PoC enhancement to make the SSO surface demo-realistic. Backend seeds two mock providers (Okta + Azure AD) and exposes three new read endpoints (claim-mapping, session-policy) the OSS admin page renders. All action buttons remain disabled with tooltips pointing at issue #847 — no real OIDC/SAML implementation yet. Public repo changes (this commit): * `src/frontend/src/views/enterprise/Index.vue` (new) — Enterprise catalogue landing page. Cards layout with status badges (SSO Available; SCIM/SIEM/License/Audit "Coming soon"). Linked from NavBar's `Enterprise` link. * `src/frontend/src/views/enterprise/SSO.vue` (rewrite) — full admin UI: - Header with "+ Add provider" button (opens modal) - Configured providers list with protocol badge, enabled/disabled indicator, issuer/metadata URL, last-login timestamp, and disabled Test/Edit/⋮ actions per row - Identity → Role Mapping table (4 rules: trinity-admins→admin, trinity-developers→creator, trinity-readonly→user, fallback) - Session Policy panel (force-SSO checkbox, session lifetime, admin-reauth checkbox — all disabled) - Add provider modal: protocol radio (OIDC/SAML), display name, provider ID, issuer URL, client ID/secret, scopes, callback URL with Copy button, enabled-on-save toggle, Cancel + Save buttons (Save disabled) * `src/frontend/src/views/Login.vue` — adds an "or sign in with" section under the email form when SSO providers are reachable. Fetches /api/enterprise/sso/providers unauthenticated (endpoint is gated by entitlement, not by user auth, so the pre-login page can call it). Two stub buttons render: "Continue with Okta" / "Continue with Azure AD". Both disabled with tooltip. * `src/frontend/src/router/index.js` — landing route + per-feature routes with two gate modes: - `meta.requiresAnyEntitlement` for the catalogue landing - `meta.requiresEntitlement: '<id>'` for per-feature pages Guard bounces non-entitled feature visits to /enterprise (when any feature is entitled) or / (otherwise). * `src/frontend/src/components/NavBar.vue` — link points at the catalogue landing (`/enterprise`). `v-if` uses `hasAnyEnterprise` so the Enterprise nav entry shows whenever any enterprise feature is registered. * `src/backend/enterprise` (submodule bump) — pulls in private repo's `feat(sso): seed PoC providers + claim-mapping + session-policy endpoints` (commit 3e90ddc). Live verification: - Default (submodule mounted): * `/api/enterprise/sso/providers` → 2 providers * `/api/enterprise/sso/claim-mapping` → 4 rules * `/api/enterprise/sso/session-policy` → SessionPolicy defaults * `/enterprise` landing renders 5 cards (SSO available, others "Coming soon") * `/enterprise/sso` renders full admin UI with all buttons disabled * `/login` shows "Continue with Okta/Azure AD" buttons under email form - `TRINITY_OSS_ONLY=1`: providers endpoint 403, NavBar enterprise link hidden, Login SSO buttons hidden. Related to #847 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(ci): lint + first-time-setup wiring for build-without-submodule Two CI issues from the previous push: 1. `lint (sys.modules pollution check)` — 6 bare `del sys.modules[...]` calls in `test_847_entitlement_seam.py` exceeded baseline (`tests/lint_sys_modules.py` requires either `monkeypatch.delitem(sys.modules, ..., raising=False)` or the sanctioned `_STUBBED_MODULE_NAMES` pattern). Replaced all six with `monkeypatch.delitem` so the auto-restore on teardown also stops the test from leaking stale module imports into sibling tests. `_import_requires_entitlement_or_skip` helper now takes `monkeypatch` as a parameter so the pattern works from inside the helper too. 2. `backend boots without enterprise submodule` — `/api/token` returned 403 on a fresh DB because `is_setup_completed()` (in `routers/auth.py:212`) gates admin login behind first-time setup, which the previous workflow didn't complete. Added a new "Complete first-time setup" step that: - greps the setup token from `docker logs trinity-backend` (the token is printed to stdout at boot per `main.py:336-343`, gated on `setup_completed != true`) - POSTs to `/api/setup/admin-password` with the setup_token + password + confirm_password fields (schema in `routers/setup.py:28-32`) Replaced the hex `ADMIN_PASSWORD` with a known-strong value that meets the OWASP ASVS 2.1 complexity check the setup endpoint enforces (length 12+, upper, lower, digit, special). Also dropped the Bearer auth from the "SSO router not mounted" assertion — the `/api/enterprise/sso/*` router is entitlement-gated, not user-gated, so the unauthenticated 404 check still proves the conditional import correctly skipped the mount. Tests: 13 passed + 2 skipped locally (no-passlib). Lint: clean against baseline. Related to #847 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(ci): complete setup via docker exec, not API grep The print() in main.py:336-343 that emits the setup token to stdout is Python-block-buffered when stdout is a pipe (docker logs capture), so the grep on a short timing window after /health returns OK is unreliable. The previous attempt extracted an empty SETUP_TOKEN and failed. Bypass the API entirely: docker exec into the backend container and write directly to the DB using the same helpers the production endpoint uses (dependencies.hash_password + db.set_setting + db.update_user_password). The flow: - set system_settings.setup_completed = 'true' - hash ADMIN_PASSWORD via the production helper - update admin user's password_hash The password is passed via docker -e to avoid shell quoting issues with the OWASP-compliant value (contains !). Verified locally: docker exec into trinity-backend, the python snippet runs cleanly and the bcrypt warning visible in CI logs is benign (passlib version mismatch, doesn't break hashing). Related to #847 * fix: print enterprise registration status instead of logger.info The enterprise conditional-import block in main.py runs at module init, which is BEFORE `lifespan` calls `setup_logging()`. Default Python logging is at WARNING level, so `logger.info(...)` records are silently dropped — neither the registered nor the OSS-only log line appeared in docker logs, breaking the CI workflow that greps for them. Switch to `print(..., flush=True)` so the output goes to stdout regardless of logger state. docker logs captures it (operators see the boot mode), and the build-without-submodule CI workflow's grep succeeds. Verified locally: `docker logs trinity-backend | grep 'Trinity Enterprise'` now shows the registered/OSS-only line. Related to #847 * fix(tests): widen except-ImportError search window in main.py static check The latest main.py commit (print(flush=True) + rationale comment) pushed `except ImportError` past the 400-char window the test was using to verify the conditional import is guarded. Widen to 1500 to absorb future small additions without breaking the static check, which is guarding the GUARD shape (try/except), not its byte position. Related to #847 * docs: enterprise modules feature flow with code links Walk-through of how the open-core split works at runtime: - boot chain (conditional import → register_enterprise → registry) - request-time gating (feature-flags endpoint, requires_entitlement) - frontend wiring (Pinia store, NavBar, route guard, Login.vue, Vue views) - failure modes (OSS-only, TRINITY_OSS_ONLY=1) - adding a new enterprise feature recipe - test surfaces + CI All sections link to file:line in the public repo and the private trinity-enterprise repo on GitHub. Complements ENTERPRISE_ARCHITECTURE.md (the 'why') and ENTERPRISE_LOCAL_DEV.md (15-min onboarding) with the 'how' at the code level. Related to #847 * feat(audit): enterprise audit log dashboard (#941) + remove SSO PoC Replaces the #847 Phase 0 SSO mock stub with the first concrete enterprise feature: an admin-facing audit log dashboard. Backend (public repo, stays OSS): - New GET /api/audit-log/distinct/event-types - New GET /api/audit-log/distinct/actor-types - Both admin-gated (Depends(require_admin)); populate dashboard filter dropdowns without hardcoding the AuditEventType enum on the frontend. - Registered BEFORE /{event_id} catch-all (invariant #4). Frontend (public OSS bundle, entitlement-gated route): - views/enterprise/Audit.vue — paginated table + filter form + side detail panel. - stores/auditLog.js — domain store (entries, filters, distinct lists, pagination, selectedEntry). Default time window = last 24h. - router/index.js — /enterprise/audit route with meta.requiresEntitlement = 'audit'. - views/enterprise/Index.vue — audit card flipped to Available; SSO card kept as Coming soon. - Login.vue — removed the SSO provider buttons mock. - Deleted views/enterprise/SSO.vue (350-line mock). Submodule (trinity-enterprise): - register_module("audit") replaces register_module("sso"). - Deleted backend/sso/{router,providers,__init__}.py. - See trinity-enterprise#feature/941-audit-registration. Entitlement model: backend stays OSS (audit_log endpoints predate the seam via SEC-001 / #20 — retroactive gating would break OSS admins). Only the OSS-side dashboard route is enterprise-gated. Tests: - New tests/unit/test_847_audit_dashboard.py (6 cases): distinct DB ops, router ordering invariant, admin gate, no-entitlement-gate pin. - Updated test_847_entitlement_seam.py: 'sso' → 'audit' assertions, new submodule static check. - New e2e/audit-dashboard.spec.js (Playwright, 4 cases). Docs: - audit-trail.md — Phase 5 row + Frontend Layer section. - enterprise-modules.md — current-state note + audit registration code. - architecture.md — distinct endpoints added to audit-log table. PR #910 scope expands to close both #847 (seam) and #941 (dashboard). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(security): CSO --diff report for #941 audit dashboard No critical or high findings. 3 low-severity stale-doc items, already deferred during /review (I3). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(audit): sync feature-flows index + add Phase 5 tests section (#941) audit-trail.md: document the new test_847_audit_dashboard.py cases + Playwright audit-dashboard.spec.js coverage. feature-flows.md index: bump audit-trail row to Phase 5 (dashboard), update Phases 1–4 row to "Merged" rather than "to follow". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(audit-dashboard): stats tiles, time presets, drill-down, verify, export (#941 v2) Expands the v1 dashboard from "viewer" to "review tool" using only existing backend endpoints — zero backend changes. Added: - Stats tiles header (Total / Top event_type / Top actor_type / Time window). Backed by GET /api/audit-log/stats; same time-window semantics as the table. Top-event/Top-actor tiles are clickable drill-downs. - Time preset chips (Last 1h / 24h / 7d / 30d / All time). Manual edits to the time fields flip the active chip to "Custom". - Inline cell drill-down: clicking an event_type cell sets that filter; clicking the actor cell filters by actor_id (or actor_type fallback). Stops row-click propagation so the side panel still opens elsewhere on the row. - Hash-chain verify badge in header. Manual button to verify the visible id range via POST /api/audit-log/verify; pill turns green ✓ on success or red ✗ with the first-invalid id on failure. - Export buttons (CSV / JSON) in the filter footer. Uses fetch + Blob + object URL so we can attach the JWT (a plain <a href> can't). Filename: audit-log-{iso-timestamp}.{ext}. Store additions: - stats / statsLoading + loadStats() - verifyState / verifyResult + verifyChain() - exporting + downloadExport(format) - activePreset + applyTimePreset(key) - drilldownFilter(key, value) helper Tests: - 2 new Playwright @smoke cases (preset chip click, event_type drill-down). Existing 4 cases unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(audit-dashboard): dow×hour + GitHub-style calendar heatmaps + foldable card (#941 v3) Adds two new admin-only audit-log endpoints and a unified foldable Activity card on the dashboard. Backend (OSS — same pattern as the existing /stats and /distinct/*): - GET /api/audit-log/heatmap — sparse 7×24 dow×hour grid - GET /api/audit-log/calendar — sparse per-day list (GitHub-style) Both honor start_time / end_time / event_type / actor_type so the two views stay coherent with the table + stats under drill-down. Frontend (OSS, entitlement-gated by 'audit'): - Single foldable Activity card with Weekly | Calendar tabs - v-show keeps both heatmaps mounted — tab swap is instant - Calendar cell click → drilldownToDay(date) narrows the dashboard to one UTC day and reloads list + stats + both heatmaps together Tests: +8 unit tests covering bucketing, filter pass-through, router ordering vs /{event_id} catch-all (invariant #4), empty-window contract. Full suite: 14 pass. Docs: architecture.md endpoint table + audit-trail.md Phase 5 v3 / v3.1 rows + endpoint descriptions + test catalog. Related to #941. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(ci): sys-modules lint + OSS-only skip for audit dashboard e2e (#941) Two CI fixes on PR #910: 1. Lint (sys.modules pollution): the audit-dashboard fixture had three bare sys.modules writes that exceeded the per-file baseline. Routed them through monkeypatch.setitem / monkeypatch.delitem so the baseline diff goes from +3 → 0. 2. Audit dashboard e2e suite was hard-coded to fail when the private enterprise submodule isn't checked out (the default on PR-time CI without a deploy-key secret). Added a per-test skip that probes for the Enterprise nav and skips the suite cleanly when absent. The route-guard / nav-hiding behavior in OSS-only mode is already covered by the unit tests in tests/unit/test_847_audit_dashboard.py. The api-keys-copy.spec.js failures in the same e2e run are a pre-existing modal-overlay flake unrelated to #941 — touched outside this PR. Related to #941. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(deploy): enterprise overlay so dev VM ships audit dashboard (#847) The Deploy-to-Dev workflow currently bypasses the enterprise submodule entirely: it doesn't init it, and the Dockerfile doesn't COPY it. The dev VM has been silently OSS-only since the seam landed. This adds an enterprise-overlay pattern that keeps the public prod image bit-identical to OSS while making enterprise features available on dev: - docker-compose.prod.enterprise.yml — single-service overlay that bind-mounts ./src/backend/enterprise into /app/enterprise (ro). The conditional `from enterprise.backend import register_enterprise` in main.py resolves the bind-mounted path on container start. - .github/workflows/deploy-dev.yml — init the submodule after pull (soft-fail with a clear marker so a missing deploy key doesn't brick the deploy), then layer the overlay onto build + up. Compose merges the volumes additively; no override of existing prod.yml mounts. - docs/dev/ENTERPRISE_LOCAL_DEV.md — new "Dev VM deploy" section with the one-time deploy-key setup (deploy key on trinity-enterprise + ~/.ssh/config alias on the VM) and the post-deploy verification step. Verified locally: `docker compose -f docker-compose.prod.yml -f docker-compose.prod.enterprise.yml config` merges cleanly; the import path resolves in the existing local container (which already bind-mounts the wider src/backend tree); /api/settings/feature-flags returns `enterprise_features: ["audit"]`. OSS path unchanged. The base Dockerfile and docker-compose.prod.yml are untouched, so build-without-submodule.yml CI guards stay green. Related to #847. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(api-keys-copy): swap UI cleanup for API DELETE — fits 30s budget (#677) The two @smoke tests in `api-keys-copy.spec.js` were timing out at the final cleanup step on slow GH-Actions runners. The pattern: Create modal → fill → Create → success modal → Copy → readClipboard → close → cleanup (revoke modal → confirm → delete modal → confirm) is ~9 sequential UI ops. On a constrained runner each takes 1-3s, so the per-test 30s budget would drain right around cleanup, and `page.waitForTimeout(200)` would resolve into "Target page closed". Replace the UI cleanup walk with `page.request.delete('/api/mcp/keys/{id}')` using the JWT from `localStorage['token']` (`stores/auth.js:201`). The backend DELETE handler hard-removes the row regardless of active / revoked state, so the UI-enforced revoke-then-delete sequence isn't needed for cleanup. The clipboard assertions earlier in the test still cover the actual #677 / #859 regression — UI cleanup was never under test. Reduces cleanup time from ~6s of UI clicks to one HTTP DELETE. Related to #677, #859. Unblocks PR #910 CI. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Add A2A v1.0 Agent Card endpoint doc (#737) - Audit Trail: cover new dashboard, heatmaps, hash-chain verify, exports (#941) - MCP Server: 62→74 tools (share_file, write_user_memory, send_message, channels); chat_with_agent gateway-timeout receipt (#914) - Agent Configuration / Managing / Scheduling: default execution timeout 15min → 60min (#665); per-schedule timeout cap rejection codes (#929) - Agent Files: new "New folder" action + mkdir endpoint (#37) - Agent Session: Reset memory vs New Session comparison (#685/#899) - Monitoring: daily retention sweeps + VACUUM (#772, #552) - Operating Room: sync-health alerts surfaced in queue (#389) - Approvals: rewritten to operator-queue model (process engine removed in #430) - Overview: drop deleted Process Engine, fix broken doc links, MCP count - Abilities marketplace drift fix: agent-dev 15→9, trinity 5→3, dev-methodology 23→15 skills (5-plugin consolidation) - README: refresh index, drop dead process-engine link, bump sync date Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Remove docs and folders preserved locally under archive/: - 4 plans for shipped features (channel-adapter SLACK-002, channel transport, ops consolidation/OPS-001, github community infra) - 4 drafts for shipped features (FILES-001 outbound x3, agent dashboard) - 4 stale point-in-time reports (Jan 2026: user-stories inventory + coverage, test-coverage gap, CLAUDE.md review May 2026) - 5 dated reports in docs/reports/ (best-practices audit, refactor audits Feb/Mar, test report) - 3 macau-797 test-run evidence files (fix landed) - tests/TEST_RECOVERY_2026-05-17.md (recovery from another user's plan) - trinity-ops-template/ scaffolding (superseded by trinity-ops-public) - Compliance/ root folder (SOC2 prep, never referenced) Relocate docs/dev/ENTERPRISE_LOCAL_DEV.md → docs/development/ (singleton in docs/dev/; consolidated under existing docs/development/). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(#913): scheduler honors per-agent execution_timeout_seconds Before this change `agent_schedules.timeout_seconds` always carried a concrete value (DEFAULT 900, later rewritten to 3600 by `_migrate_default_execution_timeout_to_3600`). The scheduler passed it to `/api/internal/execute-task`, so the per-agent fallback at `task_execution_service.py:281` was dead code on the scheduler path and `PUT /api/agents/{name}/timeout` was silently ineffective for cron- triggered runs. The canary harness fired S-03 / E-01 on every cycle on any agent whose per-agent timeout differed from the schedule default. Option A — round-trip None through the scheduler boundary: - ScheduleCreate / Schedule / ScheduleResponse / scheduler Schedule: `timeout_seconds` becomes `Optional[int] = None`. - `_row_to_schedule` (backend + scheduler) returns None instead of falling back to 900/3600. - `scheduler/service.py` carries `Optional[int]` through `_call_backend_execute_task`, `_poll_execution_completion`, `_execute_retry`. Polling deadline uses `_POLL_DEADLINE_WHEN_NULL = 7200` (the upper clamp of `PUT /api/agents/{name}/timeout`) when the schedule inherits, so the scheduler never gives up before the backend's enforcement does. - `schema.py` drops `DEFAULT 3600` on `agent_schedules.timeout_seconds`. - Migration `null_legacy_schedule_timeouts` nulls out existing rows at the historical defaults (900, 3600). Same fidelity tradeoff as `_migrate_default_execution_timeout_to_3600` — operators who explicitly chose 900 or 3600 lose that fidelity; accepted in #665, accepted here. The cleanup-side `COALESCE(s.timeout_seconds, ao.execution_timeout_ seconds, 3600)` in `get_running_executions_with_agent_info` already prefers the per-agent value when the schedule's timeout is NULL, so the watchdog termination path becomes correct automatically. Tests: `tests/scheduler_tests/test_per_agent_timeout.py` pins the round-trip — DB NULL → `Schedule.timeout_seconds is None`, dispatch payload carries `"timeout_seconds": null` (not a default integer), polling deadline math falls back to 7200s without raising, and explicit per-schedule overrides still propagate unchanged. Verified locally on the canary fleet: - canary-fleet-slow slot HASH: `timeout_seconds=180` (per-agent), TTL=480 - canary-fleet-burst slot HASH: `timeout_seconds=3600` (per-agent), TTL=3900 - 3-min live run spanning both cron cadences: 0 canary violations. Fixes #913 * fix(canary): S-03 decay-invariance — initial-TTL reconstruction S-03's `ttl < floor` raw comparison fires by ~1s on every fresh slot the moment any wall-clock time passes. Redis `TTL` returns the *current* remaining seconds, which decays linearly from `EXPIRE`, so a slot created with `EXPIRE(floor)` instantly reads `floor - 1` and trips the check — a 1-second false-positive on healthy slots. This was a latent issue masked by #913 (the schedule's `timeout_seconds` got stored as 900 → slot TTL=1200 → far below the floor of 3900, so the check always fired on a much larger margin). Once #913 makes the slot TTL match the floor exactly, the decay surfaces. Fix: reconstruct the slot's initial TTL via `ttl + age`, where `age = snapshot_time - slot_score` (slot_score is the unix epoch recorded by SlotService at ZADD time). A slot created with `EXPIRE(floor)` and aged `t` seconds satisfies `ttl + age = floor` regardless of when the snapshot is taken — invariant to wall-clock decay. A real #913-class bug where the initial TTL was `1200s` but the floor is `3900s` still surfaces as `initial_ttl < floor`. 1-second tolerance (`floor - 1`) absorbs the float→int rounding that Redis `TTL` does on the wire. A genuine slot-eviction bug class is hundreds of seconds below floor — not 1s. Test fixtures updated to use realistic acquire-time scores (`time.time()`) instead of unix epoch 1.0. Added `test_natural_decay_not_below_floor` as a regression guard. * fix(#913): address Eugene's review — guard timeout-cap check on None - routers/schedules.py: guard `_enforce_timeout_below_agent_cap` call in `create_schedule` with `if schedule_data.timeout_seconds is not None`, mirroring the already-correct UPDATE path. Without this, the rebase onto #929 would 500 with TypeError on every consumer that left `timeout_seconds` at its default (None) — the entire point of #913. Also documents the precondition on the helper's docstring so a future caller can't unknowingly reintroduce the bug. - tests/unit/test_929_timeout_validation.py: three new regression tests pinning the fix from both sides — the helper TypeErrors on None (proving the guard is necessary), `create_schedule` does not invoke the enforcer when timeout is None, and the enforcer still runs when caller sets timeout explicitly. - docs/memory/architecture.md: * S-03 description now reflects the decay-invariance fix: compares reconstructed initial TTL (`ttl + age`, where `age = snapshot_time - slot_score`) against `floor - 1`, not raw `TTL ≥ floor`. * `agent_schedules` DDL adds the `timeout_seconds INTEGER` column with a note that NULL means inherit from `agent_ownership.execution_timeout_seconds`. Verification: 9/9 tests in test_929_timeout_validation.py pass, 91/91 in tests/scheduler_tests/test_per_agent_timeout.py and tests/test_canary_invariants.py, 23/23 in tests/test_schedules.py. * test(#913): use asyncio.run() — CI Python 3.11 has no loop in MainThread `asyncio.get_event_loop()` raises `RuntimeError: There is no current event loop in thread 'MainThread'` on Python 3.11 when no loop has been created yet. Locally a prior test in the session leaves one behind so the call succeeds, but in CI the unit suite isolates more aggressively. Switch to `asyncio.run()`, which creates a fresh loop per call. Same semantics for our use case (one-shot await of a route handler).
Two independent bugs caused `GET /api/version` (and the NavBar Build Info dialog) to return `unknown` for every git/build field on the dev deployment: 1. `docker-compose.prod.yml` backend service had no `build.args` block, so the Dockerfile's `GIT_COMMIT` / `GIT_BRANCH` / `BUILD_DATE` etc. ARGs never received values during compose builds. Base `docker-compose.yml` carries the block, but the dev workflow builds with `-f docker-compose.prod.yml` only, not chained with base. 2. `.github/workflows/deploy-dev.yml` didn't compute and pass the git provenance env vars before invoking `docker compose build`. `scripts/deploy/start.sh` does this for local runs; the workflow was missing the equivalent block. Fix: - Add `args:` block to prod compose backend service mirroring base. - In the workflow, compute git vars from the dev VM's checkout and pass them inline on the `sudo docker compose build` invocation. Inline `sudo VAR=val …` is used rather than `sudo -E` because the default Ubuntu sudoers does not preserve `GIT_*` through `env_reset`. After deploy, dev's Build Info dialog will render the real commit and build timestamp instead of `unknown`. Related to #958 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sets the primary (Reliability) and secondary (UI/UX) themes as the tiebreaker when selecting tickets at the same Tier and Rank. Submodule update brings the matching workflow + /sprint skill changes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After #874 the backend and scheduler run as UID 1000 inside the container, but the dev VM's `/data` bind mount and `agent-configs` named volume were still owned by root from the prior root-container era. Earlier deploys survived because no migration *wrote* to existing rows — the first one that does (#922's `null_legacy_schedule_timeouts` UPDATE) hits `sqlite3.OperationalError: attempt to write a readonly database`, /health returns 503 forever, and `up -d` times out with "container ***-backend is unhealthy". Confirmed root cause via `docker logs trinity-backend` on the dev VM: the migration framework crashes mid-run on the SQLite write before incrementing `schema_migrations`. Fix: add an idempotent pre-flight to the deploy workflow that checks ownership and re-owns to UID 1000 only when needed: - `${TRINITY_DATA_PATH:-./trinity-data}` bind mount: `stat -c %u` then `chown -R 1000:1000` if not 1000. - `${PROJECT}_agent-configs` named volume: same check via ephemeral alpine container, chown via second ephemeral container. Both checks no-op silently on healthy VMs, so this is safe to run on every deploy. Canonical recipe matches `docs/migrations/NON_ROOT_CONTAINERS_2026-05.md`. Related to #958 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(deploy-local): write templates to host-mapped path (#950) `deploy_local_agent` was silently creating empty agents. The backend probed `/agent-configs/templates` for writability, hit the intentional `:ro` mount, fell back to `./config/agent-templates/<name>` — a path that resolves to `/app/config/...` INSIDE the backend container, with no host mapping. The new agent's bind mount (computed from `HOST_TEMPLATES_PATH`) then pointed at a host path that did not exist, so `/template` came up empty and the workspace was bare. Fix: write deployed-local templates to `/data/deployed-templates/`, which is already host-bound via `TRINITY_DATA_PATH`, writable, and owned by UID 1000 (after #874 / #969). The curated catalog at `/agent-configs/templates` stays read-only — operators' source of truth is preserved. - `services/agent_service/deploy.py`: drop the writability probe and silent fallback; always write to `DEPLOYED_TEMPLATES_DIR_IN_BACKEND`; on `OSError`, fail with HTTP 500 + `code=DEPLOYED_TEMPLATES_DIR_UNWRITABLE` pointing the operator at the non-root-volumes migration doc. - `services/agent_service/crud.py`: extend `local:` template resolution to look in both `/agent-configs/templates` (curated) and `/data/deployed-templates` (deployed). Curated wins on name collision. Bind into the new agent uses `HOST_TEMPLATES_PATH` or `HOST_DEPLOYED_TEMPLATES_PATH` accordingly. - `docker-compose.yml` + `docker-compose.prod.yml`: add `HOST_DEPLOYED_TEMPLATES_PATH` env var (defaults to `${TRINITY_DATA_PATH:-./trinity-data}/deployed-templates`) so the backend knows the host path for the per-agent bind. - `tests/test_deploy_local.py`: poll `/api/agents/{name}/files` after deploy and assert `template.yaml` + `CLAUDE.md` are visible in the agent's workspace. This catches the silent-empty-agent class. - `docs/memory/feature-flows/local-agent-deploy.md`: replace the stale probe-and-fallback description with the new single-path behavior. Related to #950 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(deploy-local): refactor to put_archive — works on dev + prod (#950) Manual end-to-end test on the local dev compose surfaced a second bug class: the bind-mount transport for `local:` templates assumed the backend's `/data` and the new agent's host bind would resolve to the same host path. That's true in prod compose (host bind for `/data`) but false in dev compose (named volume for `/data`), so any host-path math in crud.py was right on prod and wrong on dev. Net: prior version of this PR fixed prod but would still ship empty agents on dev. Refactored: drop the bind-mount transport for deploy-local entirely. Instead, pre-populate `agent-{version}-workspace` directly via `put_archive` into an ephemeral `alpine:3.20` container that mounts the workspace volume. The agent's `startup.sh` sees the `.trinity-initialized` marker we drop in the same tar and skips its `/template` -> `/home/developer` copy. Same uniform path on dev and prod — neither environment depends on host-path mapping for the deploy-local case. Curated templates (`/agent-configs/templates/...`) are unchanged; they keep the existing bind-mount transport because the curated catalog IS bound from the same host path into both backend and agent. Verified live on local docker-compose.yml stack: - POST /api/agents/deploy-local returns 200 - /home/developer is `developer:developer` (UID 1000) so the agent can write to its own home - template.yaml + CLAUDE.md + nested .claude/skills/test/SKILL.md all visible via /api/agents/{name}/files and downloadable - .trinity-initialized marker present - Agent startup creates .cache/, content/, .trinity/ without permission errors (was failing before chown of volume root) Diff vs prior commit on this branch: - deploy.py: + `_prepopulate_workspace_from_template` helper. Creates workspace volume, builds a tar with extracted template + marker entry (all uid/gid 1000), runs an ephemeral alpine container that receives the tar via put_archive and chowns the volume root to 1000:1000. Image is pre-pulled if missing. - crud.py: drop the deployed-templates branch in template_volume setup. /data/deployed-templates is still consulted by the template.yaml resolution above for redeploys, but the bind into the new agent container is no longer needed for deploy-local. - docker-compose.yml + prod.yml: drop HOST_DEPLOYED_TEMPLATES_PATH env var (no longer read by any code path). - tests/test_deploy_local.py: explicitly start the agent after deploy before polling /files (deploy returns the agent stopped). - feature-flows/local-agent-deploy.md: document the put_archive approach and why we avoid the bind-mount transport. Related to #950 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(deploy): guard local template name against path traversal (#950) CodeQL py/path-injection on PR #971 flagged two paths in crud.py where `config.template[6:]` (user-supplied after `local:` strip) is joined directly onto `/agent-configs/templates` and `/data/deployed-templates`. A `local:../../...` payload would resolve outside the templates dir, letting a `creator`-role user probe for the presence of `*/template.yaml` files on the backend FS and — if one happened to exist — have its contents pulled into `template_data` (type / resources / tools / runtime fields), surfaced via the agent config and `GET /api/agents/{name}`. The pre-existing code had the same issue; the alerts surfaced because the #950 diff touched those lines. Fixing now while the surface is fresh. Add `_validate_local_template_name` that requires `[a-zA-Z0-9][a-zA-Z0-9_.-]*` with no `..` substring, called from both the template.yaml-read block (line 282) and the template-volume- bind block (line 379). Rejection returns HTTP 400 with structured `code=INVALID_LOCAL_TEMPLATE_NAME`. Verified live: - `POST /api/agents` with `template=local:../../etc/passwd` → 400 - `POST /api/agents` with a valid name → proceeds as before Related to #950 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(deploy): use resolve()+is_relative_to barrier for path-injection (#950) Prior commit's regex-only validator passed the security test but CodeQL didn't recognise it as a barrier — `py/path-injection` alerts 174 and 175 stayed open on PR #971 after re-scan because the static analyser doesn't follow the regex barrier through the helper-function indirection. Switch to the canonical pattern CodeQL does recognise: candidate = (root / name).resolve() if not candidate.is_relative_to(root): raise Defense is now layered: 1. Regex allowlist (fail fast for obviously hostile input) 2. Resolve + is_relative_to (the CodeQL-recognised barrier) Helper renamed `_validate_local_template_name` -> `_safe_local_template_path` to reflect that it now returns the resolved path. Both crud.py callsites updated to use the resolved path directly. The `template_volume` bind in the second callsite uses `curated_path.name` instead of the raw input — `.name` is the last path component of an already-validated + resolved path, which CodeQL treats as fresh data. Behavior preserved (verified live on local stack): - POST /api/agents with template=local:../../etc/passwd -> 400 INVALID_LOCAL_TEMPLATE_NAME - POST /api/agents with a valid name -> proceeds normally Related to #950 --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ry) + orb polish (#979) (#981) * feat(voice): enrich workspace canvas — Mermaid, images, history + orb polish (#979) Add two in-process voice panel tools and frontend canvas enrichments to the voice workspace (VOICE-009), endpoint contract unchanged. Backend (services/gemini_voice.py): - show_diagram(diagram, title?) → mermaid panel type - show_image(src, title?, caption?) → image panel type - _classify_image_src(): in-process path confinement for show_image — accepts http(s) URLs and workspace-confined paths, rejects '..' traversal, absolute escapes, the /home/developer-evil sibling, data:, and non-http schemes (stricter than the agent-server prefix check) - register both in _PANEL_TOOL_NAMES + WORKSPACE_PANEL_INSTRUCTIONS Frontend (views/AgentWorkspace.vue): - mermaid: strict opaque-origin sandboxed iframe via self-contained mermaid.min.js IIFE bundle (no chunk fetches); diagram text injected as a JS string (JSON.stringify + '<'→<, no </script> breakout); securityLevel:'strict'; invalid syntax → contained error + source - image: web URLs via Vue :src; workspace paths fetched as authenticated blob via /files/preview (getFilePreviewBlob); objectURLs cached + revoked on eviction/unmount; out-of-order load guard - client-side panel history: 40-snapshot ring buffer + prev/next + dropdown - orb polish: asymmetric attack/release energy smoothing, smoothed core size, idle breathe floor, larger core/glow - cross-fade canvas transition honoring prefers-reduced-motion - iframes refactored to reactive :srcdoc bindings (transition-safe) - mermaid added to package.json Tests (tests/unit/test_voice_tools.py): show_diagram/show_image state, path-confinement rejection (traversal/sibling/data:/scheme), classifier unit tests, tool registration + declaration. Full unit suite green (1811 passed). Panel tools are backend+frontend only (not on MCP — Invariant #13 N/A). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(voice): use status-danger token for image-load error text (#979) The design-token linter (check:tokens) rejected `text-status-error-400` — `error` is not a registered status-* token. The failure/error color in the design system is `status-danger` (aliased to red). Swap the class so the build's "Verify design tokens" step passes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Eugene Vyborov <eugene@beingluminous.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…iframe (#979) (#988) * fix(voice): render workspace canvas in-parent — prod CSP blocked the iframe (#979) The Voice Workspace mermaid/HTML panels rendered inside a sandbox="allow-scripts" srcdoc iframe, which the production CSP blocks: a srcdoc iframe inherits the parent script-src 'self' (the inline render script is blocked) and its opaque origin plus Cross-Origin-Resource-Policy: same-origin blocks the mermaid bundle fetch. The canvas was blank in prod; it worked in dev only because the dev CSP allows 'unsafe-inline'. - Mermaid renders in-parent via the bundled mermaid lib (securityLevel:'strict') with DOMPurify on the output SVG (H-005). No iframe. - HTML (update_panel) panels render DOMPurify-sanitized in-parent. Agent JS (chart.js) no longer executes — static layout only; voice instructions updated to steer charts to show_diagram/show_image. - Unrelated voice run_task bug: AgentChatResponse exposes .response_text, not .response (_execute_tool). The tests encoded the same wrong attribute; moved to .response_text so they guard the regression. 58/58 voice-tool tests pass. - Wire VOICE_ENABLED / WORKSPACE_ENABLED into the backend compose environment — WORKSPACE_ENABLED was never passed through, so the canvas couldn't be enabled via .env alone. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(voice): sync voice-chat flow to in-parent canvas rendering (#979) PR #988 moved Mermaid/HTML panel rendering out of the sandboxed srcdoc iframe into the parent DOM via DOMPurify (the production CSP blocked the iframe). Sync the voice-chat feature flow to match: - in-parent DOMPurify model for mermaid (securityLevel:'strict' + SVG sanitize) and html; dropped Chart.js execution - show_diagram/show_image panel tools + mermaid/image panel types - WORKSPACE_ENABLED config row + VOICE_ENABLED compose-wiring note - test count 26 -> 58 (verified) and the .response_text regression note - architecture diagram, VOICE-008 table, and Phase 4 scope corrected Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Eugene Vyborov <eugene@beingluminous.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds `scrollbar-gutter: stable` to the document root so pages that toggle between scrolling and non-scrolling layouts don't shift horizontally. Specifically, Agent Detail's Chat / Session tabs use `h-screen overflow-hidden` (no body scrollbar) while other tabs use `min-h-screen` (scrollbar appears). The unreserved gutter caused the centered `max-w-[1400px]` panel to jerk ~15px on each tab switch. Related to #954 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…978) `docker/base-image/startup.sh` had two sites that did `echo "<pattern>" >> /home/developer/.gitignore` if a `grep -q` guard missed — once inside the GitHub-clone block (for `.local/`) and once unconditionally at end of startup (for `content/` + `.local/`). The greps used inconsistent patterns: - `grep -q ".local/"` — unanchored regex; `.` matches any char so it almost always succeeds, but on first boot of a fresh clone the file may not exist yet and the prior `[ ! -f ]` block writes a comment- only header that the grep then matches against. - `grep -q "^content/$"` / `grep -q "^\.local/$"` — anchored exact match; misses on trailing whitespace, comments, CRLF, or a file missing its terminal newline. Field hypothesis (issue body) is the trailing-whitespace / missing-newline path. Result: any template whose `.gitignore` already (correctly) contained the canonical patterns could still end up with `M .gitignore` against `origin/main` immediately after deployment — verified by reproducing in a scratch git repo: with a 2-line `.gitignore` lacking `.local/`/`content/`, simulating the startup.sh block produces `M .gitignore` and a `+.local/\n+content/` diff against `origin/main`. Fix: remove both append sites entirely. The canonical pattern list lives in `_GITIGNORE_PATTERNS` (`src/backend/services/git_service.py`) and is applied via `_build_gitignore_merge_command`'s idempotent `grep -qxF` checks (fixed-string, exact-line — no anchor false negatives). Two call sites cover the lifecycle: - `initialize_git_in_container` runs the merge on first Trinity-orchestrated git init. - `_migrate_workspace_gitignore` runs it on every push so existing agents adopt new patterns without rebuilding. Trade-off for the GitHub-clone-in-container flow: between container boot and first Trinity push, `git status` may show `.local/`-class files as untracked if the cloned template's `.gitignore` is incomplete. No remote impact — the canonical merge runs before any commit hits GitHub via Trinity's push handler. Templates whose `.gitignore` ships the patterns correctly stop drifting from `origin/main` (the AC's primary concern). Regression guard: `tests/unit/test_953_startup_sh_no_gitignore_writes.py` greps startup.sh for any `> /home/developer/.gitignore` or `>>` redirect and fails if one is reintroduced. Also asserts the replacement comment block keeps a pointer to the canonical helper so future readers can find it. Existing agents are unaffected: their already-baked `startup.sh` keeps writing patterns until they pull a new base image, at which point the canonical merge takes over via the next push. Related to #953 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…llback (#958) (#975) The Build Info dialog showed "unknown" for every field in local dev because the VERSION file was bind-mounted by docker-compose.yml only — the backend Dockerfile never COPYed it. Prod compose has no such mount, so prod also fell back to "unknown" for `version`. Bake the file into the image directly so both surfaces work without a host mount; dev's `./VERSION:/app/VERSION:ro` bind still overrides for hot edits. Add operator-facing guidance to the dialog so a "wall of unknown" isn't its own UX bug: - `useBuildInfo` exposes `isMissing` — true when every metadata field equals "unknown". - NavBar modal: blurb under the title explaining what Build Info is for; a gray-bg note ("Build metadata not available — rebuild with scripts/deploy/start.sh to populate.") shown only when isMissing. - Settings panel: same fallback message between the error and data states. The existing blurb was kept. Related to #958 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…957) (#976) Avatar Generate dialog showed only "Failed to generate avatar" with no diagnostic info — operators couldn't tell whether the failure was a missing API key, an upstream rate limit, a safety-filter rejection, or a network timeout. Root causes: - Backend returned the raw upstream exception string as the HTTP detail. In several real failure modes (nginx 504 with HTML body, network abort) the frontend got no JSON detail at all and fell back to a hardcoded generic message. - Frontend used bare `axios` instead of the shared `@/api` client (Invariant #7), with no per-status fallback chain. Backend: - `ImageGenerationResult.error_kind` — coarse classification (`not_configured` | `invalid_input` | `safety_filter` | `rate_limited` | `upstream_error` | `timeout` | `unknown`) set on every failure path. - `_classify_exception()` maps httpx + RuntimeError exceptions to a kind. - Catch blocks now use structured logging via `extra={...}` so Vector indexes agent_name, error_kind, exception_type, etc. as fields. - `_AVATAR_ERROR_HTTP` map → kind to (HTTP status, friendly detail). `generate_avatar` and `regenerate_avatar` use the map instead of hardcoded 422 + raw exception text. Service-not-available early-exit uses the same friendly text. Frontend: - `AvatarGenerateModal.vue` switched from bare `axios` to `@/api` and bumped the per-request timeout to 180s (image gen can take >30s). - `describeAvatarError(err, verb)` falls back gracefully on 502/503/504 and no-response cases so the user gets a directional message even when the upstream strips the JSON detail. Tests: - 7 new cases in `tests/unit/test_image_generation_service.py` cover `_classify_exception` and the `error_kind` field default. Related to #957 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
) (#977) * feat(sharing): notify requester on channel access-request approval (#951) After #311 introduced the unified channel access-control flow, a user who DMs an agent on Telegram/Slack/WhatsApp without prior sharing is told "I'll let you know once the agent owner responds" — but the decide endpoint never actually told them. Result: requesters were left guessing whether they'd been admitted. Wire up the proactive-message primitive (#321) to close the loop. Backend: - `ProactiveMessageService.send_access_grant_notification(agent, email, channel, text)` — one-shot path that calls `_deliver_via_channel` directly and audits the outcome. Bypasses the `allow_proactive` opt-in (the user explicitly initiated the request; this is the response) and the per-recipient rate limit (one-shot, not a campaign). Failures and missing bindings are caught, audit-logged, and returned as `DeliveryResult(success=False, ...)` — never raised. - `routers/sharing.py` decide endpoint: when `decision.approve` AND `existing.channel in {telegram, slack, whatsapp}`, fires the notification as `asyncio.create_task` so a missing binding or transport hiccup can't block the HTTP response. Rejections stay silent (deliberate — owners may not want to confirm the agent exists). Web channel handled by the existing `agent_shared` WebSocket dashboard event. Notification text mirrors the tone of the original pending-approval reply so the user recognises it as the promised follow-up: "✅ Access to {agent_name} approved by the agent owner. You can now message the agent here." Docs: - `architecture.md` decide-endpoint row + Access Control Flow note call out the post-approval notification and where the audit lands. - `feature-flows/unified-channel-access-control.md` documents the approve flow's new step plus a manual test path for each of Telegram, Slack, WhatsApp, web (negative), and missing-binding (negative). Tests: - 4 new unit cases in `tests/test_access_grant_notification_unit.py`: delivery success → audit success; `RecipientNotFoundError` → audited as `recipient_not_found`, returned not raised; unexpected exception → audited + swallowed; opt-in bypass verified. Verified live: approving a telegram-channel request on an agent with no Telegram binding returns HTTP 200, the approval succeeds, and `/api/audit-log?event_type=proactive_message` shows `success=false / error=recipient_not_found / channel=telegram / message_preview="✅ Access to trinity-system approved..."`. Related to #951 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test: snapshot/restore sys.modules in #951 unit test (lint baseline) The test was using `del sys.modules[mod]` to force a fresh import of proactive_message_service after stubbing its deps. CI's `tests/lint_sys_modules.py` baseline gate rejects bare deletions in new test files — the codebase convention is the snapshot/restore autouse fixture pattern from `tests/unit/test_telegram_webhook_backfill.py`. Switched to `_STUBBED_MODULE_NAMES` + `_restore_sys_modules` autouse fixture, then `monkeypatch.delitem(..., raising=False)` for the forced reimport. Same behaviour, no global leakage into sibling tests, lint baseline stays at 237. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
#983) * perf: cache per-agent container stats + bulk /sync-health lookup (#73) Backend CPU saturation fix for #73. Two backend-only changes: - /agents/{name}/stats: per-agent TTL cache (12s, env-overridable via AGENT_STATS_CACHE_TTL_SECONDS) + asyncio single-flight so concurrent same-agent requests share one container.stats() Docker call instead of N. A generation counter discards an in-flight leader's stale write after lifecycle invalidation; errors (404/400/500) are never cached. invalidate_agent_stats_cache is wired into start/stop/delete. - /agents/sync-health: replace the per-agent N+1 auto-sync lookup with a single scoped get_all_git_auto_sync_enabled(accessible) query, chunked at 900 IN-params to stay under SQLite's host-parameter cap. Mirrors the PERF-269 context-stats cache pattern (in-process, per-worker). No schema change, no new endpoints, no frontend change. Payload shapes byte-for-byte unchanged. Tests (14 passing, no live server): test_73_stats_cache.py (single-flight coalescing, per-agent lock isolation, TTL hit/expiry, invalidation, F4 in-flight race, 404/400 not-cached, defensive env parse, TTL=0 disable, payload parity) + test_73_sync_health_bulk.py (scope, parity, empty/unscoped, multi-chunk merge). The frontend WS-reconnect-backoff half of #73 is a tracked follow-up, not this PR. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(#73): invalidate stats cache at the Docker primitive boundary Pre-landing review (Claude + Codex adversarial; Codex rated P2) found the new per-agent stats cache was only invalidated by the start/stop/delete router handlers. Internal lifecycle paths — deploy_local, ops restart, subscription re-assign, system restart, rename — change containers via the container_stop/start/remove/rename primitives directly and bypassed those handlers, so GET /stats could serve stale "running" data for up to the cache TTL (12s) after an out-of-band restart, and a renamed-away/reused name could briefly serve a different agent's stats. Move invalidation into the docker_utils lifecycle primitives — the enforced chokepoint for every container op — so all paths invalidate, not just three routers. Remove the now-redundant router-level hooks (keep the separate PERF-269 context-stats invalidation). A function-local import breaks the docker_utils -> agent_service cycle; the helper is best-effort (logged and swallowed) so it can never break the lifecycle op it follows, and rename evicts the OLD name captured before container.rename(). Also add the 500-error-not-cached test flagged by the testing specialist. Tests: 1831 passed, 7 skipped (+7 new: 6 primitive-boundary, 1 500-path). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(#73): clarify stats-cache invalidation chokepoint + raise swallow log to warning The _invalidate_agent_stats_for docstring overclaimed 'every container lifecycle op' — the Operating Room emergency-stop fast path (routers/ops.py:_stop_agent_container) calls container.stop() directly and relies on the cache TTL (<=12s) instead. Name that exception explicitly. Bump the swallowed-invalidation log from debug to warning so a systematic failure (e.g. the lazy import regressing) is visible in logs rather than only as <=TTL stale stats; the per-call swallow is preserved so a lifecycle op never breaks. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(#73): consolidate per-agent stats cache into a single entry Merge the three parallel module-level dicts (_agent_stats_cache, _agent_stats_locks, _agent_stats_gen) into one _agent_stats dict of _AgentStatsEntry{lock, gen, data, timestamp}. Invalidation marks the slot stale and bumps gen while KEEPING the entry and reusing its lock — popping would let a leader that captured the default gen=0 match a post-pop default and repopulate a just-deleted agent's cache (the F4 race). Correct the gen-growth comment: retention is bounded by distinct agent names seen over the process lifetime, not 'fleet size'. Update the white-box tests to the consolidated structure (via an _is_cached helper) and add a caplog test asserting the invalidation-failure path now logs at warning. test_invalidation_during_inflight_discards_stale_write still passes, proving the refactor preserves the never-pop-gen guarantee. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The repo-root VERSION file (read by /api/version and baked into the backend image as /app/VERSION) was stale at 0.9.0 and had never been bumped per release. Set it to 0.6.0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…WhatsApp access, approval notifications) - agent-avatars: avatar generation failure classification + new endpoints (#957) - voice-chat: Workspace Mode canvas (BETA, admin opt-in) + panel tools - upgrading: /api/version build provenance verification step (#926/#958) - access-control: WhatsApp /login + auto-notify requester on approval (#467/#951) - add metrics report, project-board deprecation plan, workshop announcement Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
main accumulated out-of-band commits (notably 7247e2e, a 154-file commit mislabeled 'workshops banner') that duplicated or predated dev's reviewed work. All main-only code is stale, deliberately reverted (#865 SITE-001, #430 Process Engine), or relocated (.claude submodule #775, trinity-ops to separate repo). dev is the authoritative integration branch. This merge records main as a parent but keeps dev's tree wholesale so the v0.6.0 release PR (#998) merges cleanly. README marketing (GIF + workshops banner) and the explainer asset are re-added in a follow-up commit. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Brings the v0.6.0 squash commit (#998) into dev's ancestry. Trees are identical (the squash was dev's content) so this is a no-op merge commit for history continuity. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Durable, versioned release-notes document in docs/releases/ — the canonical record (the squash commit message is GitHub's concatenated per-commit bodies, not the curated notes, and no GitHub Release object was created). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace curated highlights with the complete enumerated issue list (Features 18, Security 15, Fixes 68, Refactors 7, Other 9 = 117), generated from each issue's real title + type label. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Lands the v0.6.0 release-notes document (
docs/releases/v0.6.0.md) onmain.The v0.6.0 release (#998) shipped without its notes in the repo — GitHub's squash commit is the concatenated per-commit bodies, not the curated notes, and no GitHub Release object was created. This adds the durable, complete record (all 117 issues, grouped by type) to
main.Net change: a single docs file. Going forward,
/releasegenerates this doc before opening the release PR so it ships onmainwith the tag (skill updated).🤖 Generated with Claude Code