security: Fix token logging and add HTML reports to gitignore - #7
Merged
vybe merged 2 commits intoJan 18, 2026
Merged
Conversation
Previously, the client.ts logged first 20 characters of JWT tokens and token length on every API request, which could expose sensitive information in production logs (CloudWatch, Datadog, etc.). Changes: - Add environment-aware debug logging (DEBUG_MCP_CLIENT or NODE_ENV=development) - Replace token content logging with simple auth presence indicator - In production: no token information is logged - In development: only logs whether auth is present/missing
Prevents auto-generated pytest HTML test reports from being accidentally committed. These reports are development artifacts that should remain local.
oleksandr-korin
added a commit
that referenced
this pull request
Jan 19, 2026
Test Results: - T4.1: Agent error (AGENT_UNAVAILABLE) ✅ - T4.2: Agent timeout⚠️ (bug: step status not updated) - T4.3: Retry policy ✅ - T4.4: Skip on error (on_error:skip_step) ✅ - T4.5: Cancel execution ✅ Key Findings: - Non-existent agent triggers clean AGENT_UNAVAILABLE error - on_error: {action: skip_step} works correctly - Cancel API works immediately BUG FOUND (Issue #7): - Step timeout detected (error.code='TIMEOUT') - But step status remains 'running' instead of 'failed' - Execution doesn't transition to failed state - Impact: Timeout processes may hang indefinitely Running Total: 15/22 tests passing (68%) - ABOVE TARGET ✅ Refs: PROCESS_ENGINE_ROADMAP.md Phase 1
oleksandr-korin
added a commit
that referenced
this pull request
Jan 19, 2026
Test Results: - T4.1: Agent error (AGENT_UNAVAILABLE) ✅ - T4.2: Agent timeout⚠️ (bug: step status not updated) - T4.3: Retry policy ✅ - T4.4: Skip on error (on_error:skip_step) ✅ - T4.5: Cancel execution ✅ Key Findings: - Non-existent agent triggers clean AGENT_UNAVAILABLE error - on_error: {action: skip_step} works correctly - Cancel API works immediately BUG FOUND (Issue #7): - Step timeout detected (error.code='TIMEOUT') - But step status remains 'running' instead of 'failed' - Execution doesn't transition to failed state - Impact: Timeout processes may hang indefinitely Running Total: 15/22 tests passing (68%) - ABOVE TARGET ✅ Refs: PROCESS_ENGINE_ROADMAP.md Phase 1
vybe
added a commit
that referenced
this pull request
Apr 4, 2026
Define 16 structural invariants in architecture.md that must be preserved across changes (layering, DB patterns, router ordering, auth, etc.). Reference them from CLAUDE.md as rule #7 with weekly validation cadence. Add /validate-architecture skill to check codebase compliance. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
8 tasks
This was referenced Apr 20, 2026
Closed
3 tasks
vybe
added a commit
that referenced
this pull request
May 8, 2026
…checks Add governing principle #7 to TARGET_ARCHITECTURE.md: data exchange over conversation chains as the default multi-agent composition pattern. Add Composability category (I-001–I-005) to agent-validation-spec.md: checks that agents declare output contracts, produce structured file-based outputs for downstream consumers, and enforce contracts via post-check hooks. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This was referenced May 11, 2026
4 tasks
oleksandr-korin
added a commit
that referenced
this pull request
May 21, 2026
The chat path (claude_code.py) was missing the _classify_signal_exit call that was added to the headless path (headless_executor.py) for Issue #516. When a SIGKILL terminates the claude subprocess at 0 turns (cgroup OOM, host SIGKILL, watchdog cancel), the chat handler would fall straight into _diagnose_exit_failure, which returns "Subscription token may be expired or revoked. Generate a new one with 'claude setup-token'." even when no auth signal was observed. This misclassification: - Misleads operators into chasing token regeneration when the actual cause is OOM / timeout / external kill - Pollutes the SUB-003 auto-switch trigger pattern matcher (which reads the error string), causing spurious subscription rotations on agents whose subscriptions are provably healthy - Burns the auto-switch 2-hour skip-list slot on phantom auth failures Fix mirrors the existing pattern in headless_executor.py:683 — call _classify_signal_exit first, fall through to _diagnose_exit_failure only for non-signal exits. No new logic; the classifier already produces the honest "Execution terminated by SIGKILL after N tool calls / N turns" message. Adds a structural regression test (parametrized over both files) that pins the call ordering — _classify_signal_exit must appear before _diagnose_exit_failure in both call sites, otherwise the auth-fallback heuristic re-introduces the misclassification. Deployment: requires base image rebuild + agent restart for the fix to take effect on running agents (per CLAUDE.md note #7). Out of scope: Fix 2 (gate auto-switch on observed wire 401/403/429) and Fix 3 (cgroup OOM event reading) — both flagged in #906 as follow-up improvements. Fixes #906 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
12 tasks
oleksandr-korin
added a commit
that referenced
this pull request
May 27, 2026
New `GET /api/agents/{name}/schedules/{schedule_id}/analytics` endpoint
returns counts, success rate, duration p50/p95/p99, cost total, tool-call
top-5 by total wall time, and a UTC daily timeline. Default window 7d
(also 24h / 30d). Inline `ScheduleAnalyticsCard.vue` renders inside
`SchedulesPanel.vue`'s expanded-schedule region — pure CSS, no Chart.js.
Implementation notes (locked by /autoplan + /review):
- Percentiles via `statistics.quantiles(method="inclusive")` over the
newest 5,000 success rows (`_PERCENTILE_ROWSET_CAP`). Counts and
timeline use the full unsampled rowset. `sampled` flag in response.
- Tenant boundary in DB layer (`schedule.agent_name != agent_name`
→ None → 404). `AuthorizedAgent` only validates the URL agent name;
user-supplied `schedule_id` is verified against ownership.
- Tool-call top-5 weighted by `sum(duration_ms)` per tool (not count),
avoiding `Read`/`Bash` dominating low-signal frequency leaderboards.
- Timeline gap-filled Python-side; UTC bucketing via
`substr(started_at, 1, 10)`; documented on the route.
- `window_hours` server-validated to `{24, 168, 720}` → 422 otherwise.
- Soft-deleted schedules return 404 (matches `get_schedule()` policy).
- Frontend uses the shared `api` client (CLAUDE.md invariant #7) and
`useFormatters().formatDuration` composable.
Per-agent rollup and per-chat-session analytics deferred — see issue
body for the destination map (#18 / follow-up).
12 unit tests cover percentile correctness, time-window boundary,
empty + all-running edge cases, NULL duration exclusion, malformed
JSON skip, cross-tenant 404, soft-deleted 404, sampling boundary,
timeline gap-fill, tool-call duration weighting.
CSO diff scan: zero findings (8/10 confidence gate).
Fixes #868
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
6 tasks
vybe
pushed a commit
that referenced
this pull request
Jun 1, 2026
…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>
vybe
pushed a commit
that referenced
this pull request
Jun 2, 2026
…Settings (#995) (#996) * feat(enterprise-ui): User & Org Management view on the #847 seam (#995) Public OSS-bundle frontend for the private user_management module (Abilityai/trinity-enterprise#2). Gated entirely server-side by the `user_management` entitlement — hidden in OSS-only builds and bounced by the route guard on direct URL visits. - views/enterprise/UserManagement.vue: org list + create, membership add/remove, seat counts. Light + dark. No algorithmic IP (CRUD glue over the private /api/enterprise/user-management/* endpoints). - stores/orgManagement.js: domain store, calls via shared axios + auth header (Invariants #6/#7). - router: /enterprise/user-management gated meta.requiresEntitlement: 'user_management' (mirrors the audit route). - views/enterprise/Index.vue: add the catalogue card (available). No public backend/schema/model changes — the entire data model + logic lives in the private submodule per the enterprise open-core split. The submodule pointer is intentionally NOT bumped here; it advances after trinity-enterprise#2 merges. Verified: all four files compile; the only build blocker is the pre-existing unrelated `mermaid` import in AgentWorkspace.vue (stale local node_modules; resolved by CI npm ci). Related to #995, #847 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(enterprise-ui): per-user activity audit in Settings → User Management (#995) Integrates the enterprise activity view INTO the existing OSS user management table (not a separate page). When user_management is entitled, each user row gets a "View activity" action opening a drawer with that user's audit summary + timeline, fetched from the private /api/enterprise/user-management/users/{id}/activity endpoint. - Gated entirely by enterpriseStore.isEntitled('user_management') — column + drawer hidden in OSS-only builds. - No change to the existing role-CRUD behaviour; purely additive column. - loadFeatureFlags() in onMounted (cached/no-op when NavBar already ran). Related to #995 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(user-mgmt): OSS deactivation primitive + enterprise lifecycle UI (#995) Pivots #995 from Organizations to the real net-new gap — user onboarding/offboarding — integrated into the existing Settings → User Management table (not a separate page). OSS primitive (edition-agnostic, small): - users.suspended_at column + migration + surfaced in get_user/list_users. - get_current_user rejects suspended users (both JWT + MCP-key paths), so setting the column blocks new logins AND invalidates live tokens on the next request. - /api/users exposes suspended_at (read-only) so the gated UI can render Deactivate/Reactivate. Enterprise UI (gated by user_management entitlement, hidden in OSS): - Settings → User Management gains an "Invite user" form, per-row Deactivate/Reactivate (not for self or the built-in admin), and the per-user Activity drawer. All call the private /api/enterprise/user-management/* endpoints. Removed: the separate /enterprise/user-management Orgs page, its route, store, and Index card (orgs dropped — single-tenant). Index card now points at Settings. No change to the existing OSS role-CRUD behaviour. Verified live: /api/users carries suspended_at; suspend/reactivate/invite/activity all work; OSS-only builds hide every enterprise control. Related to #995, #847 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(user-mgmt): stop Management column clipping in User Management table (#995) The users table wrapper was overflow-hidden; the extra entitlement-gated Management column pushed total width past the card and clipped the right-side action buttons. Switch to overflow-x-auto so the wider table scrolls within the card instead of clipping. Related to #995 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(user-mgmt): fit User Management table in the card (no h-scroll) (#995) Replace the overflow-x-auto stopgap with an actual fit: trim cell padding px-6→px-4 across the table and let the Management actions wrap within their column (flex-wrap, text-xs, no whitespace-nowrap). The 5-column table now fits the max-w-4xl settings card without clipping or a horizontal scrollbar. Related to #995 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test: add suspended_at to schedule-soft-delete test users DDL (#995) The #995 users.suspended_at primitive added the column to _USER_COLUMNS, so get_user_by_*() now SELECTs it. test_schedule_soft_delete builds its own users table with a hardcoded DDL that lacked the column, causing "no such column: suspended_at" (4 regression-diff failures). Mirror the schema change in the test DDL. Related to #995 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(architecture): document enterprise modules + two-track migrations (#995/#997) - users.suspended_at deactivation primitive (OSS column + enforcement; enterprise-only setter) on the users table + a callout. - Invariant #3 extended: enterprise migrates enterprise_* tables via a separate runner tracked in enterprise_schema_migrations (one file per migration; never ALTERs OSS tables; runs after OSS init). - feature-flags doc gains enterprise_features. - New "Enterprise Modules (#847 seam)" section: audit / user_management / siem entitlements, surfaces, and the gating model. Related to #995, #997, #847 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(enterprise): enterprise registration failure must not crash core boot (#995/#997) main.py wrapped register_enterprise(app) in `except ImportError` only — so a bug in enterprise registration (schema init, migration, router mount, pusher start) would propagate and crash backend startup on an enterprise build. Add a broad `except Exception` that logs loudly + a traceback and continues in OSS-only mode. Modules registered before the failure stay active; the rest are simply absent from feature-flags. The core platform always boots. OSS-only builds are unaffected (still the ImportError path). Verified: happy path still boots (health 200) and registers ['audit','siem']. Related to #995, #997, #847 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This was referenced Jun 2, 2026
vybe
added a commit
that referenced
this pull request
Jun 9, 2026
Phase 2 of #740: adds a Loops tab on the Agent Detail page over the existing dev backend (routers/loops.py, loop_service.py) — no backend changes. - stores/loops.js: agent-scoped Pinia store on the shared api.js client (Invariant #7). Filters fleet-wide loop_run_completed/loop_completed WS events by the mounted agent, targeted-refreshes only the affected loop, and runs a 12s backstop poll while any loop is queued/running to recover a missed terminal event. - components/LoopsPanel.vue: Run-loop form (message template w/ {{run}} + {{previous_response}} helper, max_runs, stop_signal, delay, timeout, ModelSelector, allowed_tools), loop list with status/runs/stop_reason, expandable per-run table, last response via DOMPurify renderMarkdown, cooperative Stop control. - AgentDetail.vue: Loops tab between Schedules and Playbooks. - websocket.js: route loop events to the store in the type-keyed branch. - e2e/loops-panel.spec.js + architecture/feature-flow docs. Verified live: tab renders, form submits, loop row reaches terminal state via the live-update path, expanded detail renders the per-run table. Closes #1106 Co-authored-by: Eugene Vyborov <eugene@beingluminous.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
7 tasks
vybe
added a commit
that referenced
this pull request
Jun 19, 2026
…#1149) Surface per-schedule performance on the Overview tab and the Schedules tab, both from a SINGLE compact aggregate (no N per-schedule round-trips) — extends #1107 (Overview) and generalises #868 (per-schedule deep analytics). Backend: - db `get_agent_schedules_summary(agent, hours)` — one rollup row per non-deleted schedule (zero-run schedules included): terminal success_rate (success / (success + failed[incl. error]); None when zero terminal), NULL-skipping avg_duration_ms, cost_total, context_avg, tool_call_total (parsed over newest 5,000 rows agent-wide, tool_calls_sampled flag), and last-run outcome. Cheap grouped SQL; iso_cutoff window (Invariant #16). - GET /api/agents/{name}/schedules/analytics-summary?window=7d|14d|30d (AuthorizedAgent). Declared BEFORE /{schedule_id} in routers/schedules.py so the literal segment isn't captured as a schedule_id (Invariant #4) — putting it in analytics.py would be shadowed (schedules_router mounts first). - models: ScheduleSummaryRow + AgentSchedulesSummaryResponse (Invariant #14). Frontend (single fetch, two consumers — Invariant #7): - executions.js fetchSchedulesSummary, cached per ${name}:${window} like fetchAgentAnalytics. - OverviewPanel: "Schedules performance" section, honors the existing 7/14/30d window selector, each row deep-links to the Schedules tab; hidden at zero. - SchedulesPanel: inline mini-stats per row (success rate, avg duration, runs, last-run dot) — badge style, no new chart/modal — from the same call. Tests: tests/unit/test_1115_schedules_summary.py (6) — terminal success rate, NULL-skip avg, tool-call total, zero-run-still-appears, soft-delete excluded, out-of-window excluded. Full analytics suites green (30 passed). Frontend prod build clean; endpoint verified live across 7/14/30d windows. Related to #1115 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Eugene Vyborov <1073874+vybe@users.noreply.github.com>
This was referenced Jun 24, 2026
This was referenced Jun 29, 2026
vybe
pushed a commit
that referenced
this pull request
Jul 6, 2026
…terprise#78) (#1476) * feat(enterprise): Client Portal "My Agents" roster page (trinity-enterprise#78) Add the first client-facing Client Portal surface to the OSS bundle, gated by the `client_portal` entitlement (backing module in trinity-enterprise#91): - `views/enterprise/ClientPortal.vue` — a card grid of the agents shared with the signed-in email (avatar or deterministic initials tile, owner, shared date; a disabled "Chat — soon" affordance signals the next slice). Loading / empty / error states; dark-mode; falls back to initials if an avatar URL fails to load. - Route `/enterprise/client-portal` gated `meta.requiresEntitlement: 'client_portal'` (302s to the enterprise catalogue when unentitled; 404 from the backend in OSS-only builds). - `stores/clientPortal.js` — domain store; `fetchRoster()` over the gated `/api/enterprise/client-portal/my-agents` endpoint (Invariant #7: API via store, not the view). - Enterprise catalogue card in `views/enterprise/Index.vue`. The Vue ships in the OSS bundle (no IP) but stays hidden until the enterprise module registers `client_portal` — safe to land independently of the submodule bump. Related to trinity-enterprise#78. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(enterprise): Client Portal chat with a rostered agent (trinity-enterprise#78) Make the roster cards actionable: a "Chat" button opens a slide-over chat drawer for that agent. Second Client Portal slice, on top of the "My Agents" roster. - `views/enterprise/PortalChat.vue` — slide-over chat panel (user/assistant bubbles, typing indicator, textarea composer, Enter-to-send). Assistant markdown rendered via `utils/markdown.js` (DOMPurify, Invariant H-005). - `ClientPortal.vue` — the card "Chat — soon" placeholder is now a live "Chat" button that opens the drawer for the selected agent. No new backend: chat reuses the existing OSS `POST /api/agents/{name}/chat` (via `agentsStore.sendChatMessage`), which already authorizes a user whose email is on the agent's `agent_sharing` allow-list — exactly the roster set. The portal remains the entitlement-gated surface; the chat capability itself is the OSS one a shared user already has. Verified live (PG): as the shared user, chatting a rostered agent through the portal's endpoint returns a real agent reply. SFCs compile clean. Related to trinity-enterprise#78. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(portal): verified-email portal session — OSS fence + public sign-in page (trinity-enterprise#78) The Client Portal's real client identity: a verified email, not a platform account. OSS owns the edition-agnostic primitive + the security fence; the enterprise module (trinity-enterprise#91) mints the token after code verification. OSS (`dependencies.py`) — mirrors the MFA-challenge-token pattern: - `create_portal_session_token(email)` / `decode_portal_session(token)` — a scope=`portal_session` JWT carrying only the email (no `sub`, no users row). - Fence: `get_current_user` and `decode_token` reject a `portal_session` token, so it authenticates NOTHING on the platform — only the entitled portal endpoints accept it (via the module's `get_portal_identity`). Verified live: 401 on `/api/agents` and `/api/settings`, 200 only on the portal roster. Frontend — public client surface: - `views/Portal.vue` at `/portal` (standalone, no NavBar, no `requiresAuth`): email → 6-digit code → roster of the agents shared with that email. A client signs in with no platform account. - `stores/clientPortal.js`: `requestCode` / `verifyCode` / `signOut`; the portal token is persisted (localStorage) and used as the auth header for the portal endpoints, falling back to platform login for operator preview. A 401 drops the token back to the sign-in form. Chat over a portal session is the next slice (the OSS chat endpoint fences the portal token by design); the roster card shows "Chat — soon" in the public view. Related to trinity-enterprise#78. Depends on trinity-enterprise#91 (mint + endpoints). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
vybe
added a commit
that referenced
this pull request
Jul 8, 2026
* fix(agent): make agent /tmp tmpfs size configurable via AGENT_TMP_SIZE (#1231) (#1233)
Agent containers mounted /tmp as a hardcoded 100 MB noexec,nosuid RAM-backed
tmpfs. It fills easily — e.g. `gh` CLI install artifacts (~38 MB) that hardcode
/tmp and bypass the #1098 TMPDIR redirect — after which every /tmp write fails
with "No space left on device", including git's commit scratch, so autonomous
scheduled runs "complete" but silently fail to persist. The size being a
literal meant operators couldn't tune it without a code change + base-image
rebuild.
- capabilities.py: AGENT_TMPFS_MOUNT size now read from AGENT_TMP_SIZE (env on
the backend service, which builds the agent mount spec), default 512m,
validated `^\d+[mg]$` with empty/invalid → default. noexec,nosuid stay
hardcoded — only size is configurable, and it stays bounded (counts against
the agent memory cgroup). Single source of truth, so create (crud.py) and
recreate (lifecycle.py) can't drift.
- Wire AGENT_TMP_SIZE=${AGENT_TMP_SIZE:-512m} on the backend service in both
docker-compose.yml and docker-compose.prod.yml; document in .env.example.
- architecture.md Container Security: note the now-configurable size.
- tests/unit/test_1231_agent_tmp_size.py: default, valid m/g, case-fold,
invalid→default, and the security flags are never dropped.
Mount specs are creation-time: existing agents pick up a new size on recreate,
not restart. Builds on #1098 (TMPDIR redirect) — closes the gap for tools that
hardcode /tmp. The agent-side git-sync silent-persist-failure is a separate
issue in the abilities repo, per the ticket.
Related to #1231
Co-authored-by: Eugene Vyborov <1073874+vybe@users.noreply.github.com>
* feat(ui): per-schedule performance scorecards on Agent Detail (#1115) (#1149)
Surface per-schedule performance on the Overview tab and the Schedules tab,
both from a SINGLE compact aggregate (no N per-schedule round-trips) — extends
#1107 (Overview) and generalises #868 (per-schedule deep analytics).
Backend:
- db `get_agent_schedules_summary(agent, hours)` — one rollup row per
non-deleted schedule (zero-run schedules included): terminal success_rate
(success / (success + failed[incl. error]); None when zero terminal),
NULL-skipping avg_duration_ms, cost_total, context_avg, tool_call_total
(parsed over newest 5,000 rows agent-wide, tool_calls_sampled flag), and
last-run outcome. Cheap grouped SQL; iso_cutoff window (Invariant #16).
- GET /api/agents/{name}/schedules/analytics-summary?window=7d|14d|30d
(AuthorizedAgent). Declared BEFORE /{schedule_id} in routers/schedules.py
so the literal segment isn't captured as a schedule_id (Invariant #4) —
putting it in analytics.py would be shadowed (schedules_router mounts first).
- models: ScheduleSummaryRow + AgentSchedulesSummaryResponse (Invariant #14).
Frontend (single fetch, two consumers — Invariant #7):
- executions.js fetchSchedulesSummary, cached per ${name}:${window} like
fetchAgentAnalytics.
- OverviewPanel: "Schedules performance" section, honors the existing 7/14/30d
window selector, each row deep-links to the Schedules tab; hidden at zero.
- SchedulesPanel: inline mini-stats per row (success rate, avg duration, runs,
last-run dot) — badge style, no new chart/modal — from the same call.
Tests: tests/unit/test_1115_schedules_summary.py (6) — terminal success rate,
NULL-skip avg, tool-call total, zero-run-still-appears, soft-delete excluded,
out-of-window excluded. Full analytics suites green (30 passed). Frontend
prod build clean; endpoint verified live across 7/14/30d windows.
Related to #1115
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Eugene Vyborov <1073874+vybe@users.noreply.github.com>
* feat(ui): in-app bug reporting from the floating Help widget (#1116) (#1283)
* docs(readme): document the Trinity Ops Agent and PostgreSQL backend/migration (#1290)
Adds a top-of-README callout recommending PostgreSQL for production (SQLite remains the zero-config dev default, opt-in via DATABASE_URL, #300), links the public Trinity Ops Agent (trinity-ops-public) for instance operations, and documents migrating existing SQLite instances via its /migrate-to-postgres skill. Also adds a Database section, a DATABASE_URL env row, and an ops-agent entry in the docs index.
Co-authored-by: Eugene Vyborov <eugene@beingluminous.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(reliability): unify the SUB-003 auth-class failure classifier into one shared module (#1088) (#1297)
The `is_auth_failure` + `AUTH_INDICATORS` + `NON_AUTH_KILL_MARKERS` (#904)
logic was duplicated inline in `subscription_auto_switch.py` and
`scheduler/service.py`, kept in sync by a hand-written "keep these lists in
sync" comment — exactly how the #904 kill-marker bug class re-appears.
Consolidate into one canonical module:
- New `src/backend/services/failure_classifier.py` — canonical, pure-stdlib
classifier (55 lines). `subscription_auto_switch.py` now re-exports
`is_auth_failure` unchanged, so existing importers
(`routers/chat.py`, `services/task_execution_service.py`) and their test
patch targets keep working.
- New `src/scheduler/failure_classifier.py` — byte-identical vendored mirror.
The scheduler runs in a separate container and cannot import
`backend.services`; it uses the classifier for log-labelling only (picks the
`logger.error` wording, never gates a switch). The agent-runtime classifier
in `error_classifier` is intentionally NOT merged — it diverges semantically
and stays kill-safe by `_classify_signal_exit` precedence (D4).
- Byte-identity is enforced by
`tests/unit/test_904_sigkill_no_false_auth.py::TestBackendSchedulerParity`;
the re-export is pinned by `TestBackendReExportGuard`. No hand-sync.
Pure structural refactor, no behavioral change (verified by SHA-256 equality
of the two copies and line-for-line comparison vs the deleted code). The test
rewrite also drops the prior `exec(compile(...))` source-slicing in favour of
`importlib` path-loading, removing the only injection primitive in scope.
Tests: 19/19 pass in `test_904_sigkill_no_false_auth.py`.
CSO --diff: CLEAR (docs/security-reports/cso-diff-2026-06-21.md).
Refs #1088
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(orchestration): pull-pilot routing for agent→agent MCP chat behind default-OFF flag (#946) (#1293)
* feat(orchestration): pull-pilot routing for agent→agent MCP chat behind default-OFF flag (#946)
Phase 2 PoC for pull/work-stealing (Epic #1045, umbrella #1081). When
MCP_AGENT_CHAT_PULL_ENABLED is ON, a sequential agent→agent (scope='agent',
non-self) chat_with_agent is routed by the MCP server through the durable async
/task path instead of the synchronous held /chat; the caller gets an immediate
{accepted|queued, execution_id} receipt and polls get_execution_result.
scope='user', self-tasks, and parallel=true are unchanged. Default OFF — flag
flip + MCP routing revert is the whole rollback.
- config.py: canonical MCP_AGENT_CHAT_PULL_ENABLED registry entry (both services
read the SAME env key, so a single-.env deploy can't drift).
- settings.py: surface mcp_agent_chat_pull_enabled in /api/settings/feature-flags
(auth-gated, observability-only — not a UI surface).
- chat.py: release the idempotency claim on the two /task dispatch-breaker-open
(CircuitOpen) deny paths, mirroring /chat and CapacityFull (T5 fix) — without
it a breaker-open reject silently blocks same-key retries for 24h.
- mcp-server: scope-based pull routing + D8 dispatch-mode idempotency token so a
flag flip can't replay a wrong-shape snapshot across endpoints; startup log of
the routing mode for the soak's control/treatment window.
- tests: chat.test.ts (routing fork + key behavior), test_946_task_idempotency_on_deny.py
(deny-path claim release), feature-flag exposure tests.
- docs: ACTOR_MODEL_POSTCARD (#945 resolved), PULL_PILOT_946_SOAK harness +
go/no-go record, CSO diff audit (CLEAR), TARGET_ARCHITECTURE/architecture updates.
Refs #946
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(feature-flows): document pull-pilot routing (#946) + AGENT_TMP_SIZE tmpfs (#1231)
Sync feature-flow docs with recent changes:
- agent-to-agent-collaboration.md: new Pull-Pilot Routing (#946) section —
flag-gated MCP routing fork to the durable async /task path, poll-for-result
receipt contract, D8 idempotency route token, feature-flag exposure, and the
T5 /task dispatch-breaker-open deny-path claim release.
- container-capabilities.md: refresh stale tmpfs facts — agent /tmp size is now
operator-configurable via AGENT_TMP_SIZE (default 512m, noexec,nosuid fixed),
plus the TMPDIR=/home/developer/.tmp heavy-scratch redirect (#1098).
- feature-flows.md: add Recent Updates index rows for #946 and #1231.
Refs #946
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Eugene Vyborov <eugene@beingluminous.com>
* feat(agent): runtime data_paths with portable export/import (#1169) (#1294)
* feat(agent): runtime data_paths with portable export/import (#1169)
Declare an agent's runtime data (SQLite DBs, datasets) under data/ on the
already-durable home volume — no separate volume, no platform schema change.
- template.yaml `data_paths:` surfaced by template_service (github + local)
and materialized at creation by crud.py -> git_service.materialize_data_paths:
writes ~/.trinity/data-paths.yaml and appends data/ + entries to the agent's
own .gitignore (idempotent). Opt-in; empty list is a no-op.
- S4 persistent-state and data_paths now share one extracted heredoc/list
primitive (materialize_trinity_yaml_list / _read_trinity_yaml_list).
- New routers/agent_data.py: POST /data/export (stream | base64, 413 over cap,
manifest-only tar when data/ missing) and POST /data/import (proxies to the
agent-server restore primitive; data/** allowlist + traversal guard;
Idempotency-Key). Both serialized per agent by a cross-worker Redis op lock.
- MCP tools export_agent_data / import_agent_data (Invariant #13).
- Validation checks DP-001..DP-005 in agent-validation-spec.
- Docs: architecture, requirements, feature-flows index + agent-data-volumes
flow, agent guide; CSO diff audit report (0 critical/high).
Tests: ~30 unit + TestClient tests (export/import endpoints, allowlist,
gitignore, template surface). PR2 (scheduled snapshots + pre-snapshot
quiesce hook + retention/cascade) deferred.
Closes #1169
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(agent-data): satisfy sys.modules pollution lint in #1169 tests
The two new data_paths test files copied the baselined `patch.dict` +
bare `del sys.modules[...]` loader from test_persistent_state_allowlist.py,
which the sys.modules pollution lint flags as NEW (non-baselined) violations.
Adopt the blessed snapshot/restore exception (precedent:
test_telegram_webhook_backfill.py): declare a top-level
`_STUBBED_MODULE_NAMES` list + an autouse `_restore_sys_modules` fixture, and
install the stubs / evict the cached module directly (the fixture owns
restoration). Removes the bare `del sys.modules[...]` entirely rather than
hiding it. Drop the now-unused `patch` import from the gitignore test.
Lint passes (no new violations); both files' 16 tests still green; no
cross-file leakage.
Refs #1169
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(feature-flows): sync recent changes — #1231 tmpfs, #1115/#1231 index rows
Fix container-capabilities.md for the now-configurable agent /tmp tmpfs
(#1231): default 100m → 512m via AGENT_TMP_SIZE, and correct the stale
full_capabilities ternary excerpts to the shared AGENT_TMPFS_MOUNT constant
(noexec,nosuid always applied, both modes). Add Recent Updates index rows for
the per-schedule performance scorecards (#1115) and the tmpfs-size fix (#1231);
the #1169 and #1116 rows already shipped in-commit.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Eugene Vyborov <eugene@beingluminous.com>
* fix(security): authenticate the in-container agent server on the shared agent network (#1159) (#1292)
* fix(security): authenticate the in-container agent server on the shared agent network (#1159)
The in-container agent server (:8000) had zero inbound auth on
trinity-agent-network: any agent could read a sibling's .env secrets or
run arbitrary Claude on it. Every backend->agent call now carries a
per-agent X-Trinity-Agent-Token = HMAC-SHA256(AGENT_AUTH_SECRET,
"trinity-agent-auth:v1:"+name), verified by a pure-ASGI middleware on all
HTTP and WebSocket routes (constant-time compare; only /health exempt).
- Derive-don't-store: the master AGENT_AUTH_SECRET lives only in the
backend env (auto-generated by start.sh like SECRET_KEY); each container
receives only its own token, so a compromised agent cannot compute a
sibling's. Fail-closed -- derive raises on an empty secret.
- Callers route through services/agent_auth.py (agent_httpx_client /
build_agent_auth_headers / merge_auth_headers); a static guard test
fails any new raw agent-{name}:8000 caller that bypasses them.
- Removed the dead, unauthenticated /ws/chat route (ran arbitrary Claude)
and the agent server's wildcard CORS (internal-only).
- Grace path for old images: empty TRINITY_AGENT_AUTH_TOKEN -> allow;
check_agent_auth_token_env_matches forces a one-pass recreate to inject.
- Retired the unused src/scheduler/agent_client.py.
Tests: unit (matcher, middleware, header guard, derivation) + security
isolation test. CSO diff audit in docs/security-reports/cso-diff-2026-06-20.md.
Closes #1159
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(feature-flows): document agent-server authentication (#1159) + sync recent flows
Add a feature-flow doc for the in-container agent-server inbound auth shipped
in this PR, and sync the index with two recent merged changes.
- New feature-flows/agent-server-authentication.md: end-to-end trace of the
derived X-Trinity-Agent-Token (HMAC over AGENT_AUTH_SECRET), the pure-ASGI
middleware enforcing it on every HTTP/WS route, fail-closed vs grace path,
recreate reconciliation, and the migrated callers + static guard. Added to
the Authentication & Security catalog in the index.
- container-capabilities.md: refresh the stale /tmp tmpfs size (was a hardcoded
100m) to the configurable AGENT_TMP_SIZE (default 512m, #1231).
- Recent Updates rows for #1159, #1231, and the previously-missing #1115.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Eugene Vyborov <eugene@beingluminous.com>
* docs(voip): genericize moved-issue reference in feature-flow
#1039 (configurable data-retention) moved to the private enterprise
tracker; replace the now-private issue number in voip-telephony.md with a
generic description so the public doc doesn't deep-link a private issue.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(agents): server-side compatibility validation with auto-fix (#668)
Run ~100 best-practice checks (11 categories) against a running agent's
workspace, surfaced non-blocking in the Overview tab with one-click
auto-fix for the 10 gitignore checks, plus an MCP tool.
- services/compatibility/ package (spec/collector/static_checks/ai_checks/fixes):
ONE docker exec -> in-container python -> JSON snapshot (secret files
existence-only, size/binary caps); pure STATIC checks (HARD-only) +
category-batched AI checks (Haiku, iterate-expected, fail-open, capped at
SOFT, secret-redacted); runtime-aware (claude-only checks skipped for
Codex/Gemini).
- GET/POST endpoints (read AuthorizedAgentByName; fix OwnedAgentByName, gitignore
only, per-agent Redis lock, atomic write, uncommitted until next sync;
include_ai path rate-limited). agent_compatibility_results table (dual-track
SQLite + Alembic) persists the latest snapshot; cascade/rename via AGENT_REFS.
- CompatibilityPanel.vue (two-phase fetch, grouped checklist, per-check fix,
re-run) in OverviewPanel; get_agent_compatibility_report MCP tool.
- 35 fixture-driven unit tests; spec sync-tested against docs/agent-validation-spec.md.
Persistence departs from the issue's "no DB table" note so AI verdicts show
without re-spend + enable fleet aggregation (see requirements section 41).
Fixes #668
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(compat): remove polynomial-ReDoS in secret-assignment regex (#668)
CodeQL py/polynomial-redos (high): `_ASSIGN_RE` captured the value as
`[ \t]*(.+?)[ \t]*$`. The lazy `.+?` and the surrounding `[ \t]*` can both
match a tab, giving polynomial backtracking when `_redact()` runs the pattern
over up to 48 KB of agent-supplied file text.
Capture the value greedily to end-of-line (`(.*)$`) and let the callers
strip — both `_looks_placeholder()` callers already `.strip()`, so secret
detection and redaction are behaviourally identical (verified: `=`, `:`,
`export`, and indented forms still match). 35 unit tests pass.
* feat(sso): OSS gated surface for enterprise SSO (OIDC) (#32)
Companion to trinity-enterprise#36. OSS carries only the entitlement-gated
surface; all SSO logic lives in the private submodule.
- Login.vue: "Sign in with <IdP>" buttons (shown only when the `sso` feature is
entitled and a provider is enabled), plus OIDC callback-fragment handling
(`/login#sso=ok|mfa|error`) — reuses the existing 2FA challenge UI when the
IdP login still requires a local second factor.
- stores/auth.js: completeSsoLogin() (reuses _finalizeLogin / _setMfaChallenge)
+ fetchSsoProviders() (empty in OSS-only builds — endpoint 404s).
- Settings.vue: admin-gated "SSO" tab → SsoPanel.vue (provider CRUD + test +
policy). Gated by enterpriseStore.isEntitled('sso'), same as the 2FA tab.
- Bump enterprise submodule to the SSO module commit.
- docs: architecture enterprise-modules row + requirements §40 (SSO/OIDC).
No new backend dependency (python-jose + httpx already in the image) and no
OSS Python changes — the mint/whitelist/mfa seams already exist.
Stacked on feat/5-2fa-totp (reuses the OSS mfa_gate + 2FA challenge surface).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(sso): bump enterprise submodule to OIDC hardening (#32 review)
Pulls in the email_verified / issuer-pinning / login-CSRF fixes
(trinity-enterprise 87c8f97). OSS gated surface unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(planning): note incubating goal-directed direction; reword voip flow
Add an "Incubating Directions (Not Yet Decided)" section to
TARGET_ARCHITECTURE.md capturing the goal-directed control-surface idea
(Objective + policies + roster + externally-measured evals), explicitly
bounded by CLAUDE.md §8 and sequenced after the pull migration + #300.
Incubating in trinity-enterprise#27.
Reword the voip-telephony flow note to drop a stale #1039 reference in
favor of describing the LOG_* data-retention no-op class directly.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(security): add CSO 2026-06-21 posture report
Routine /cso full-audit posture report (Phases 0–14, daily 8/10 gate).
Follows the docs/security-reports/ convention; no real secrets reproduced.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(user-docs): video library + per-page links, v0.6.1 What's New, sync dev features
- Add videos.md (35 published videos, newest-first by topic) and a README Watch section
- Add 'Watch' callouts to 32 feature pages linking the most relevant, newest videos
- Add user-facing whats-new/v0.6.1.md (translated from release notes; no issue numbers)
- Document dev-only features: agent runtimes (Claude Code/Codex/Gemini CLI, #1187),
agent data paths + export/import (#1169), compatibility validation (#668),
in-app bug reporting (#1116), subscription hot-reload (#1089), pull-pilot routing (#946),
configurable AGENT_TMP_SIZE (#1231), Postgres migration-runner groundwork (#1160)
- Index agent-runtimes, agent-data, and the previously-orphaned agent-session pages
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(setup): first-run operator intake + admin email login (abilityai/trinity-enterprise#38, #82)
Capture an optional operator email/company at first-run setup with an explicit,
unchecked-by-default opt-in to "occasionally receive important security & product
updates", submitted once to a new /v1/operator-intake endpoint on #1116's
Cloudflare intake app. The same email binds as the admin's sign-in identity so
the operator can log in with email + password — no verification email is sent (a
fresh install has no Resend key; the email is bound, not code-verified). The
code-based email second factor stays Phase 2 on the existing mfa_gate seam.
- backend: operator_intake_service (fire-and-forget, at-most-once via a
system_settings marker, DO_NOT_TRACK aware, owns installation_id); setup
endpoint captures profile + binds admin email; authenticate_user resolves the
admin by username OR registered email (password guard blocks code-only users);
PUT /api/users/me/email for the existing-admin transition
- frontend: SetupPassword email/company + consent checkbox; Login "username or
email" field; Settings -> General "Admin sign-in email" card
- config: OPERATOR_INTAKE_ENABLED / OPERATOR_INTAKE_URL (+ .env.example)
- docs: requirements section 43, architecture catalog, first-time-setup feature flow
- tests: 16 unit tests (intake idempotency/guards, email-login resolution, setup)
Fixes abilityai/trinity-enterprise#38
Fixes #82
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(feature-flows): index row for first-run intake + admin email login (trinity-enterprise#38, #82)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(setup): de-ambiguate email regex to clear CodeQL polynomial-ReDoS (#82)
The _EMAIL_RE pattern duplicated into setup.py and users.py had two
[^@\s]+ atoms around the literal \. that both also match '.', giving the
engine many ways to place the dot and backtracking polynomially on
user-controlled email input (CodeQL alerts #211, #212).
Constrain only the final segment to [^@\s.]+ (no dot) so the trailing \.
can align with exactly one position -> linear matching. Behaviour is
unchanged: multi-subdomain addresses still validate; an 80k-char
pathological input now resolves in ~2ms.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(credentials): curated credential file-type injection (SA keys, certs, SSH, binary) (#1305)
* feat(credentials): curated credential file-type injection — SA keys, certs, SSH, binary (enterprise#11)
Widens CRED-002 injection from the fixed 3-path exact allowlist
(.env/.credentials.enc/.mcp.json) to a curated set of credential file *types*,
without reopening the arbitrary-path RCE surface (#183/#590/#598).
- New services/credential_paths.py — single-source policy: ALLOW (.config/gcloud/**,
.kube/config, *.pem/*.key/*.crt/*.cert/*.p12/*.pfx, .ssh/id_*, + existing exact set)
with deny-precedence over anything executed/sourced at startup (shell rc,
CLAUDE.md/AGENTS.md/.claude/**, .mcp.json.template, .ssh/authorized_keys/config,
.git*, bin/**) and `..`/absolute traversal. Vendored byte-identically into the
agent image (Invariant #5) with a parity test.
- Agent-server hardening: the inject + update file loops now enforce the policy AND
a resolve-under-home traversal guard the original write path lacked; parent-dir
creation + chmod 0o600 preserved. New GET /api/credentials/list for export discovery.
- Binary-safe: inject carries files_b64 (base64); agent writes via write_bytes.
.credentials.enc gains a v2 {files, files_b64} envelope (legacy flat archives still
decrypt); encrypt/decrypt stay flat for the single-secret callers (SIEM/2FA/SSO).
- Export now captures the FULL injected set (via /list) + binary, not just the 2 defaults.
- Three surfaces in sync (Invariant #13): MCP inject_credentials gains files_b64;
frontend CredentialsPanel gains a file-upload affordance (text vs base64 auto-detected).
- Tests: allowlist test now exercises the REAL policy (newly-allowed + still-blocked),
+ credential_paths parity test + binary archive round-trip test. 61 pass.
- docs/memory/architecture.md: credential-path policy documented.
Related to Abilityai/trinity-enterprise#11. Loosens a deliberately-tight boundary —
run /cso on the diff before merge.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(credentials): close /cso findings on the injection widening (#11 review)
Security review of the widening surfaced one HIGH regression + hardening items;
all fixed here.
#1 (HIGH, RCE): the #598 .mcp.json content-validation guard was bypassable via
the new files_b64 (binary) channel — validate_mcp_config only checked `files`,
so `files_b64={".mcp.json": base64(<stdio-command MCP server>)}` skipped it and
configured an RCE MCP server on the target agent. Fix: .mcp.json may only arrive
as TEXT (files), where it is validated; rejected in files_b64 at the backend
inject router AND the agent-server write helper.
#2 (defense-in-depth): import/auto-import wrote decrypted archives via the
agent-server /inject layer only. Added validate_credential_set() (curated path
policy + .mcp.json content + no-binary-.mcp.json) on the backend import boundary
so enforcement is dual-layer as the issue mandates. (Archives are AES-GCM with
the server key, so a forged archive wasn't practical — but the layer belongs.)
#3: .ssh/ is now locked to id_* only — a stray *.key/*.pem under .ssh is no
longer accepted (policy was previously broader than the "SSH keys = id_*" intent).
#4 (noted): .config/gcloud/** can hold a google-auth executable credential_source;
only honored under non-default GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES=1.
Documented in credential_paths.py.
+6 regression tests (169 pass). CSO report: docs/security-reports/cso-2026-06-22-11-diff.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(credentials): exclude vendor dirs from cert globs + accurate export count (#11 live test)
Found while testing PR #1305 against a real local instance:
1. Over-capture: the broad *.pem/*.key/*.crt globs matched bundled CA files
(e.g. .local/.../site-packages/certifi/cacert.pem), so export's /list walk
swept vendored cert material into .credentials.enc. Added node_modules,
site-packages, .local, .venv/venv, .cache, go/pkg to the deny-list (both
root and nested forms) so cert globs only catch real credential files.
2. export's files_exported count re-read just the 2 default files (reported 1
while the archive actually held 5). export_to_agent now returns the true
captured count; dropped the redundant stale read.
Verified end-to-end on a live agent: allowed types inject (text+binary, 0600,
parent dirs), blocked paths 400 (incl. .ssh non-id_*, .mcp.json-via-files_b64,
weaponized .mcp.json text), and binary round-trips through export→import with
matching sha256. +5 regression tests (72 pass).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(security): annotate CSO 2026-06-21 findings as remediated; drop stale voip hunk
Two review-driven fixes ahead of the v0.7.0 cut:
- Annotate the CSO posture report (.md + .json) with post-audit remediation
status. The report audited `main` pre-cut and listed F1/F2/F3 as open
VERIFIED findings; they are already remediated on `dev` and ship in v0.7.0:
- F1 (unauth agent-server) -> #1159 X-Trinity-Agent-Token middleware
- F2 (fastmcp -> hono/undici) -> #1255, #1289; fastmcp ^4.3.0
- F3 (form-data CRLF via axios) -> #1254
- F4/F5/F8 exploit path closed by #1159 (auth gate)
Adds a top-of-report banner, per-row status tags, per-finding notes, and a
machine-readable `remediation_status` block in the JSON. Avoids publishing a
stale "open CRITICAL + exploit" to a PUBLIC repo without its fix context.
- Drop the voip-telephony.md reword: it is superseded by already-merged #1301,
which made the identical `#1039` -> `LOG_*` change on `dev`. Restoring to
merge-base removes the redundant/conflicting hunk from this PR.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore: set version to 0.7.0
* feat: streamline first-time setup wizard (abilityai/trinity-enterprise#49)
Drop the log-copied setup token, require an admin email, and rebuild the
first-run page as a welcoming animated welcome screen.
Backend (routers/setup.py, main.py):
- Remove the setup-token machinery entirely (ensure_setup_token /
clear_setup_token / Redis-shared token + the main.py startup emission).
Setup no longer depends on Redis — the admin write goes straight to SQLite.
- Make admin email REQUIRED (sign-in identity): missing -> 422 at the model
layer; blank/typo -> 400, validated before any write so setup never
half-completes. Password complexity (OWASP ASVS 2.1) still enforced.
- get_setup_status keeps setup_available:true for frontend back-compat.
Frontend (SetupPassword.vue):
- Full redesign: dark branded hero with an animated orbiting fleet
constellation (Trinity mark core + agent nodes on three rings), split
layout (stacks on mobile), prefers-reduced-motion aware.
- No setup-token field; email required; order email -> password (+confirm)
-> company -> updates opt-in. Removed the Redis-wait panel + polling.
Security tradeoff (chosen: accept + document): removing the token leaves the
unauthenticated first-run window with no proof-of-control. Documented as an
operator responsibility (deploy behind a tunnel/VPN until setup completes) in
docs/DEPLOYMENT.md Security Recommendations; endpoint still self-disables
after first success. See docs/security-reports/cso-diff-2026-06-23.md (F1).
Docs: DEPLOYMENT.md security note, architecture.md, requirements.md
(§15.2/§43), feature-flows/first-time-setup.md.
Tests: remove obsolete test_1165_setup_token_shared.py; update test_setup.py
(no token, email required) and test_setup_operator_profile.py (email
required, model-layer + blank/invalid rejection). 7 operator-profile unit
tests pass; new contract verified live (422/400 negative paths).
Fixes abilityai/trinity-enterprise#49
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(setup): full-bleed setup screen via normal flow, not position:fixed
The redesigned first-run page used `position:fixed; inset:0` for its root.
On wider viewports this left a band of the light `#app` (bg-gray-100)
background showing through on the right/bottom — a fixed root is clipped to
the nearest transformed/contained ancestor instead of the viewport, so its
coverage isn't guaranteed.
Switch the root to the original component's proven normal-flow approach
(`position:relative; width:100%; min-height:100vh`), which fills the
full-width `#app`, and make the decorative aurora/grid `position:absolute`
within it. Verified covering the full viewport at 2560x1440 (light mode, the
repro case) and stacking correctly at 430px.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(tests): defer routers.setup import so unit collection can't be corrupted
The CI backend-unit regression gate runs `cd tests && pytest unit/` (the whole
unit suite). test_setup_operator_profile.py imported `routers.setup` at module
(collection) time; that import — pulling in database/dependencies/services and
their many `utils.*` leaves — failed/perturbed sys.modules during collection and
INTERRUPTED the entire `unit/` collection (head collected ~2 of 2734 → the diff
gate flagged it as a new failure).
Defer the `import routers.setup` to a cached `_get_setup()` accessor used inside
the tests, so module collection imports only stdlib/pytest/fastapi/pydantic and
can never corrupt the suite. `_get_setup()` also spec-preloads the backend
`utils.*` leaves (helpers/errors/credential_sanitizer/password_validation/
url_validation/image_optimize) the same way conftest preloads `utils.helpers`,
without touching `sys.modules["utils"]`, so the import resolves cleanly at run
time regardless of harness utils state.
Verified with the exact CI command (`cd tests && pytest unit/ --co`): the full
suite now collects 2734 items with no interruption, and the 7 setup tests pass.
No conftest changes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(tests): drop sys.modules preload — plain lazy import (passes #762 lint)
The previous commit's `_get_setup()` spec-preloaded backend utils leaves via
`sys.modules[...] = …` / `.pop`, which tests/lint_sys_modules.py (#762) bans
outside conftest. It's also unnecessary: in the backend-unit gate
(`cd tests && pytest unit/`), tests/unit/conftest.py already installs
src/backend/utils as the canonical `utils` package, so a plain lazy
`import routers.setup` resolves the backend `utils.*` leaves natively.
Simplify `_get_setup()` to a cached plain lazy import — no sys.modules
mutation. Verified: lint clean (no new violations), `pytest unit/ --co`
collects 2734 with no interruption, and the 7 setup tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(tests): update #858 guards for setup-token removal (#49)
Removing the setup token (trinity-enterprise#49) deleted
`routers/setup.py::ensure_setup_token` and the lifespan token emission, so two
#858 regression guards asserted gone behavior and failed in the backend-unit
gate:
- test_ensure_setup_token_logs_token_via_logger_warning
- test_lifespan_emits_setup_token_via_logger_before_event_bus
The #858 invariant itself is intact: the lifespan still emits the first-run
notice via `logger.warning` (not print), after setup_logging() and before
event_bus.start(). Replace the token-specific guard with one that matches the
new FIRST-TIME SETUP warning by content + ordering, drop the now-obsolete
ensure_setup_token guard, and remove the unused BACKEND_SETUP constant. The
Dockerfile PYTHONUNBUFFERED parity checks and the no-print-in-lifespan guard are
unchanged. 4 passed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(whatsapp): deliver ChannelResponse.files as Twilio MediaUrl (#1315)
WhatsApp agents can now send files to users. send_response delivers
ChannelResponse.files as Twilio MediaUrl attachments (one message per file,
text first), reaching parity with the Slack adapter.
- New create_share_from_bytes() persists in-memory bytes through the FILES-001
pipeline (MIME-blocklist/quota/disk/DB) and mints a public ?sig= URL; both it
and create_share now share the extracted _persist_and_register helper.
- Per-agent file_sharing_enabled gate; 1h share TTL (cleanup reaper purges).
- Caps (image/audio/video ~5MB, documents ~16MB) on the detected MIME; graceful
text-link fallback when public_chat_url is unset/non-HTTPS, the MIME is
unsupported, or the file is oversized — never silently dropped.
- Per-file isolation: a rejected/failed file never aborts the text or siblings.
- 42 unit tests; requirements.md + whatsapp-integration.md updated.
Fixes #1315
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(whatsapp): webhook routes to backend, not frontend (#1281) (#1316)
The WhatsApp panel's deployment-prerequisite notice told operators to route
/api/whatsapp/webhook/* to the "frontend service". That path is a backend
FastAPI route (Twilio HMAC-verified); pointing tunnel ingress at the static
SPA silently drops inbound messages. Corrected to the backend service
(http://backend:8000), matching the cited PUBLIC_EXTERNAL_ACCESS_SETUP.md.
Related to #1281
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(ui): loading skeletons for Dashboard graph & timeline (#1266) (#1312)
The initial fleet/metrics load can take 20s+ on 10+ agent fleets (#1265);
until now the Dashboard rendered the "No agents" empty state (or blank
timeline) during that wait, so the UI looked frozen/broken.
- New reusable `SkeletonLoader.vue` (dark-mode aware, accessible
role=status/aria-busy, reserves space to avoid layout shift) with `rows`
(timeline/list) and `nodes` (collaboration graph) variants.
- `stores/network.js`: add `loading` (defaults true so the first paint is a
skeleton, not the empty state) + `loadError` (distinct failed-load state),
toggled in `fetchAgents` (finally-cleared so a failure never shows an
infinite skeleton).
- `Dashboard.vue`: graph canvas and timeline now render skeleton → error →
empty → content off those flags. Loading shows immediately on nav; error
states offer a Retry (reuses `refreshAll`).
Frontend-only; pairs with the backend perf work in #1265. `vite build` passes.
Related to #1266
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(db): set SQLite end-of-support to September 1, 2026 + Postgres migration notes (#1278) (#1314)
Records the firm SQLite end-of-support date and the SQLite → PostgreSQL
migration announcement/guidance. Documentation/decision only — SQLite code
removal stays with the migration work (#300/#1183/#746).
- docs/migrations/SQLITE_TO_POSTGRES.md (new): authoritative guide — EOL date,
what changes and when, switching a fresh deployment (DATABASE_URL + postgres
profile), migrating an existing deployment (backup-first; no turnkey data-copy
tool yet — honest cutover options), verification, and release-notes copy.
- docs/releases/v0.6.2.md (new, draft): EOL announcement section linking the
guide, seeding the next release notes.
- docs/planning/TARGET_ARCHITECTURE.md + docs/memory/architecture.md (Invariant #3):
reference the EOL date so it's discoverable outside the release.
- Cross-links the in-repo reminder companion (#1279).
Related to #1278
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs: stop disclosing enterprise functionality in public docs (trinity-enterprise#45) (#1311)
* docs: stop disclosing enterprise functionality in public docs (trinity-enterprise#45)
The public repo documented the full design, feature catalog, and gating
strategy of the paid enterprise tier — a free blueprint of what we monetize
and how it's built. This removes that competitive content and keeps only the
generic open-core seam public.
- Delete 4 strategy/design docs (OSS_ENTERPRISE_SPLIT_RESEARCH,
ENTERPRISE_ARCHITECTURE, feature-flows/enterprise-modules, ENTERPRISE_LOCAL_DEV)
- architecture.md "Enterprise Modules" table -> neutral seam pointer
(no paid-feature catalog, no enterprise_* table DDL, no per-module detail)
- requirements.md §35 -> abstract EntitlementService seam (drop the enumerated
module list + dead links to the deleted strategy docs)
- audit-trail.md: neutralize the lone enterprise-pillar mention
- CLAUDE.md: standing rule — enterprise designs live only in trinity-enterprise
- CI: enterprise-docs-guard.yml fails the build if live public docs reintroduce
paid-feature / private-schema tokens
Content is preserved (relocated to the private trinity-enterprise repo, see the
companion PR). Git-history scrub of the deleted files + point-in-time historical
docs (archive/, releases/, security-reports/) tracked as a follow-up.
Related to Abilityai/trinity-enterprise#45
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ci(enterprise-docs-guard): add least-privilege permissions block
Clears CodeQL actions/missing-workflow-permissions (medium). The guard only
checks out and greps, so contents: read is sufficient.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(releases): add 0.7.0 release notes
* fix(#1115): port get_agent_schedules_summary to SQLAlchemy Core (Postgres-safe)
The #300 SQLAlchemy migration dropped the get_db_connection import from
db/schedules.py but left get_agent_schedules_summary (#1115) calling it,
so the /schedules/analytics-summary endpoint raised NameError at runtime.
Surfaced for the first time by the v0.7.0 release-PR full-suite run (dev
pushes only lint).
Port the method to get_engine() Core queries like its siblings, and
replace the SQLite-only bare-column-with-MAX last-run query with a
portable ROW_NUMBER() window so it works on PostgreSQL too.
Also refresh the test_login_rate_limit_split config stub, which went
stale when auth.py grew a PUBLIC_ACCESS_REQUESTS_ENABLED dependency
(trinity-enterprise#10) — 8 collection errors under HEAD.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(voip): per-agent VoIP config panel + persisted voice (abilityai/trinity-enterprise#28)
Add the missing per-agent VoIP config UI (agent Settings/Sharing tab) and a
persisted per-agent Gemini voice. Shipped as plain OSS gated on the existing
voip_available platform flag — NOT entitlement-gated (a UI gate over a
money-spending OSS backend would be cosmetic; deliberate simplification of the
issue's original "entitlement-gated" framing).
Backend:
- agent_ownership.voice_name (default Kore) via dual-track migration
(SQLite db/migrations.py + Alembic 0004 + schema.py/tables.py). db
get/set_voice_name with read-path fallback to Kore for unset/invalid values.
- GET/PUT /api/agents/{name}/voice/name (PUT owner-only, validated against
GEMINI_VOICE_NAMES). _get_voice_name and voip_service now read the persisted
voice instead of the two hardcoded "Kore" sites.
- PUT /api/agents/{name}/voip/enabled toggle (owner-only, 404 when no binding);
create_binding upsert no longer forces enabled=1 so re-saving credentials
preserves a disabled state (call path already refuses disabled bindings).
Frontend:
- VoipChannelPanel.vue (modeled on WhatsAppChannelPanel) mounted in SharingPanel
under voip_available; shared src/constants/voices.js (drift-guarded vs backend);
AgentWorkspace picker defaults to the persisted voice; sessions store surfaces
voip_available.
Tests: tests/unit/test_28_voip_voice_config.py — voice fallback/roundtrip/
invalid->default, enable toggle + re-PUT-preserves-disabled (H3), and the
frontend/backend voice-list drift guard. Schema-parity + voip-db guards green.
Refs abilityai/trinity-enterprise#28
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(voip): HTTP-level endpoint tests for /voice/name + /voip/enabled (#28 review I1)
Pre-landing /review flagged that the new endpoints were covered only at the DB
layer. Add FastAPI TestClient tests (mount real routers, override auth deps, stub
db/voip_service) asserting:
- PUT /voice/name: owner-gated (403), 400 on unknown voice, empty clears to
default, valid voice persists; GET returns voice_name + available_voices.
- PUT /voip/enabled: owner-gated (403), 404 when no binding / when voip flag off,
200 reflecting state with no auth_token leaked.
Also capture a durable learning (docs/memory/learnings.md): the schema-parity
test is blind to db/tables.py drift — a missing Column there passes parity but
breaks at runtime; guard it with a db-accessor unit test that executes a live
select on the new column.
Refs abilityai/trinity-enterprise#28
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(docs): agent-readable zero-to-value onboarding (#1280) (#1336)
* refactor(docs): agent-readable zero-to-value onboarding (#1280)
Make the repo's entry points machine-first so an autonomous agent can self-orient and reach a useful result without a human translating context.
- AGENTS.md: add a "Using this file" machine-contract header (declares it the authoritative agent entry point, states the AGENTS/CLAUDE/README boundary, explains how to traverse). Rebuild Route-by-task with an explicit "Done when" zero-to-value signal per persona.
- CLAUDE.md: cross-link to AGENTS.md and frame CLAUDE.md as the contributor working agreement (auto-loaded by Claude Code), not the agent landing page.
- Fixes from a context-free agent onboarding test (AC#5 validation): AGENTS.md deploy verify no longer assumes an undefined $TOKEN (leads with `trinity agents list`, shows token derivation); deploy section states the running-instance prerequisite; README CLI example adds the `trinity agents list` verify step; docs/CLI.md leads with `pip install trinity-cli` (PyPI) and marks `-e src/cli/` as the from-source/dev variant.
Validated by an agent performing a zero-to-value deploy task using only repo files, no human context: self-oriented in 2 hops (README -> AGENTS.md) to a correct deploy+verify answer; friction items above are its findings, folded back in.
Repo-root AGENTS.md is hand-authored; the CLAUDE.md->AGENTS.md mirror (#1187) is per-agent-container (startup.sh), so these edits are conflict-free.
Related to #1280
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* docs(templates): machine-readable starter-template catalog (#1280)
Affordance sweep for agent self-selection. Previously an agent had to `ls`
config/agent-templates/ (24 dirs, 7 of them test fixtures) and open each
template.yaml to find a starting point.
- config/agent-templates/README.md: catalog grouping the 17 real templates
(single-purpose: scout/sage/scribe/demo-*/trinity-system; the dd-* due-
diligence suite) with one-line affordances, how-to-use, and an explicit
"not starting points" list for the test/canary fixtures
- AGENTS.md: link the catalog from the Deploy-an-agent section so the
zero-to-value path is "pick a ready-made template", not "author from scratch"
Related to #1280
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* fix(tests): un-quarantine and fix the 15 unmasked unit failures (#1103) (#1338)
Removes the @pytest.mark.skip quarantines added in #300 and fixes the
underlying issues. Each group fixed at root cause, not by matching assertions
to current behavior.
Environmental (git identity):
- tests/unit/conftest.py: set GIT_AUTHOR/COMMITTER_NAME/EMAIL process-wide so
in-test `git commit` works on a CI runner with no global git identity
("Author identity unknown"). Fixes test_reset_preserve_state_guardrails (3)
and the test_git_pull_branch end-to-end setups.
Test-setup bug (production code was correct):
- test_git_pull_branch.py: the repos did `git push -u origin main` but `git init`
defaults to `master` (no init.defaultBranch), so origin/main never existed and
_get_pull_branch correctly fell back to the working branch — the assertions
expecting "main" failed. Force `git init -b main` (local + bare). Fixes all 5
(TestGetPullBranch 2 + TestGitPullFromMainEndToEnd 3).
Test-isolation bug (assertions were correct):
- test_orphaned_execution_recovery.py: shared module-level mocks were reset with
plain reset_mock(), which keeps return_value/side_effect — so one test's
get_agent_container.side_effect bled into later tests under random ordering,
skewing recovery counts ("assert 3 == 2"). Reset with
reset_mock(return_value=True, side_effect=True). Stable across 5 seeds.
Real lint findings:
- docker/base-image/startup.sh: shellcheck now exits 0. Converted the 4 fragile
file-iteration loops to `find -print0 | while read` (SC2010/SC2045/SC2044),
hardened 8 `cd` with `|| exit 1` (SC2164), split the SC2155 export, and
documented-disabled SC2001 on the two regex `sed` lines that ${//} can't
express. `bash -n` clean. Un-skips test_startup_sh_shellcheck_clean.
backlog (3) and 929 (1) were already un-quarantined on dev (db_harness schema),
so no change needed there.
Verified: the 11 un-skipped tests pass across multiple random seeds; full unit
suite shows no regressions from these changes (the unrelated pre-existing
test_1115_schedules_summary / test_admin_email_login failures fail on dev too).
Related to #1103
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* chore(deps-dev): bump happy-dom (#1326)
Bumps the patch-and-minor group in /tests/git-sync with 1 update: [happy-dom](https://github.com/capricorn86/happy-dom).
Updates `happy-dom` from 20.10.5 to 20.10.6
- [Release notes](https://github.com/capricorn86/happy-dom/releases)
- [Commits](https://github.com/capricorn86/happy-dom/compare/v20.10.5...v20.10.6)
---
updated-dependencies:
- dependency-name: happy-dom
dependency-version: 20.10.6
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: patch-and-minor
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* chore(deps-dev): bump @types/node in /src/mcp-server (#1327)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 25.9.3 to 26.0.0.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)
---
updated-dependencies:
- dependency-name: "@types/node"
dependency-version: 26.0.0
dependency-type: direct:development
update-type: version-update:semver-major
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* chore(deps): bump fastmcp (#1325)
Bumps the patch-and-minor group in /src/mcp-server with 1 update: [fastmcp](https://github.com/punkpeye/fastmcp).
Updates `fastmcp` from 4.3.0 to 4.3.2
- [Release notes](https://github.com/punkpeye/fastmcp/releases)
- [Commits](https://github.com/punkpeye/fastmcp/compare/v4.3.0...v4.3.2)
---
updated-dependencies:
- dependency-name: fastmcp
dependency-version: 4.3.2
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: patch-and-minor
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* chore(deps): bump the patch-and-minor group (#1329)
Bumps the patch-and-minor group in /src/frontend with 5 updates:
| Package | From | To |
| --- | --- | --- |
| [axios](https://github.com/axios/axios) | `1.18.0` | `1.18.1` |
| [@playwright/test](https://github.com/microsoft/playwright) | `1.61.0` | `1.61.1` |
| [autoprefixer](https://github.com/postcss/autoprefixer) | `10.5.0` | `10.5.1` |
| [@rollup/rollup-darwin-arm64](https://github.com/rollup/rollup) | `4.62.0` | `4.62.2` |
| [@rollup/rollup-linux-arm64-musl](https://github.com/rollup/rollup) | `4.62.0` | `4.62.2` |
Updates `axios` from 1.18.0 to 1.18.1
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.18.0...v1.18.1)
Updates `@playwright/test` from 1.61.0 to 1.61.1
- [Release notes](https://github.com/microsoft/playwright/releases)
- [Commits](https://github.com/microsoft/playwright/compare/v1.61.0...v1.61.1)
Updates `autoprefixer` from 10.5.0 to 10.5.1
- [Release notes](https://github.com/postcss/autoprefixer/releases)
- [Changelog](https://github.com/postcss/autoprefixer/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/autoprefixer/compare/10.5.0...10.5.1)
Updates `@rollup/rollup-darwin-arm64` from 4.62.0 to 4.62.2
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.62.0...v4.62.2)
Updates `@rollup/rollup-linux-arm64-musl` from 4.62.0 to 4.62.2
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.62.0...v4.62.2)
---
updated-dependencies:
- dependency-name: axios
dependency-version: 1.18.1
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: patch-and-minor
- dependency-name: "@playwright/test"
dependency-version: 1.61.1
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: patch-and-minor
- dependency-name: autoprefixer
dependency-version: 10.5.1
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: patch-and-minor
- dependency-name: "@rollup/rollup-darwin-arm64"
dependency-version: 4.62.2
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: patch-and-minor
- dependency-name: "@rollup/rollup-linux-arm64-musl"
dependency-version: 4.62.2
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: patch-and-minor
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* feat(executions): propagate cancelled terminal status end-to-end (#679) (#1333)
Defense-in-depth follow-up to #671. Make the agent task-runner aware that an
operator cancel happened and surface a third terminal outcome — `cancelled` —
alongside success/failed, so a cancel is never recorded as a billable success
or an agent failure.
Agent server:
- ProcessRegistry records a `_terminated[execution_id]` marker on a successful
SIGINT send; `was_terminated()` (read-only, 300s lazy TTL, cleared on
register) lets the sync chat handler and async result callback relabel a
graceful-exit-0 / SIGKILL->504 turn as cancelled.
- `record_task_finish` accepts a neutral finish (success=None): a cancel
neither resets nor increments the dispatch-breaker failure counter (#526).
Backend:
- 3-way status map (success->SUCCESS, cancelled->CANCELLED, else->FAILED) in
the async callback (routers/agents.py) and the sync applier
(task_execution_service). An auth/rate terminal is never reclassified as a
cancellation — guarded at the backend trust boundary too (CSO finding 2).
- Consumers (message_router, chat, paid, public, validation_service) treat
cancelled as non-delivery; paid no longer settles on cancel (money bug).
- terminate writes CANCELLED only when it actually stopped a running turn; on
already-finished the agent's real terminal stands (Issue 7).
Tests: 9 new unit suites (64 cases) + execution-termination integration
additions; 85 unit tests pass locally. CSO diff audit: CLEAR.
Refs #679
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(ui): unify Chat + Session into one Chat tab with a session-mode toggle (#1112) (#1340)
Collapse the redundant Chat + Session tabs on Agent Detail into a single "Chat"
tab carrying a "Session mode" toggle (default ON), keeping the legacy stateless
surface as a first-class user-selectable mode rather than dead code.
- AgentDetail.vue: single `{ id: 'chat' }` tab (drop the separate Session entry).
New `chatMode` ref ('session'|'legacy', default 'session') persisted per-user in
localStorage['trinity.chatMode']. `sessionAvailable` = feature flag on AND
runtime has --resume (not Codex); `effectiveChatMode` forces legacy when the
Session surface is unavailable and hides the toggle. The toggle swaps
SessionPanel ↔ ChatPanel in-place (v-if). isFullscreenTab keys on the single
'chat' id; `?tab=session` aliases to 'chat' (hinting session mode).
- Execution-resume: ExecutionDetail "continue as chat" (?tab=chat&resumeSessionId)
forces legacy ChatPanel (which owns resume) via a transient, non-persisted
routeForcedMode — without rewriting the user's saved preference.
- No backend change (session_tab_enabled already exists). MobileAdmin unaffected:
its openChat is a self-contained mobile chat overlay, not an AgentDetail
deep-link, so there is nothing to repoint.
- docs: architecture Session Tab block + requirements §5.8 note.
Related to #1112
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* feat(access): Access tab — manage Trinity operators per agent (trinity-enterprise#17) (#1317)
New Access tab on Agent Detail that manages Trinity operators (platform users)
with access to an agent, distinct from the Sharing tab's external channel
clients. Draws the operator-vs-client line on the read path.
Backend:
- db.get_agent_operator_access(): outer-joins agent_sharing × users on the
grantee email (lower-cased, engine-based → PG+SQLite). Resolved → active
operator (username/role/last_active); unresolved → pending invite.
- GET /api/agents/{name}/access (AgentOperatorAccess model). Add/remove reuse
the existing /share + /share/{email}.
Frontend:
- AccessPanel.vue: operator roster (status + role badges, last-active), add by
email, remove. Access tab wired into AgentDetail (owner-gated).
- SharingPanel.vue: Team Sharing allow-list removed (moves to Access); dead
share-management script + stale "Team Sharing below" copy cleaned/repointed.
- stores/agents.js: getAgentAccess().
Tests: active-vs-pending classification + agent scoping. vite build passes.
Note: the strict client-vs-operator split (non-user emails → a dedicated client
roster) is deferred to the Sharing-side redesign (#18/#20); removing them here
now would orphan those grants, so all allow-list entries stay visible on Access.
Related to Abilityai/trinity-enterprise#17
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(ui): slim the Overview executions-by-type bars
The "Executions by type" stacked bars rendered full-width with a 1px
gap, so a busy agent's week read as a solid wall of color. Cap each
bar at 56px and center it inside its (still full-width) hover column,
and soften the top corner. The column stays flex-1 so spacing/tooltips
are unchanged and wider windows (14d/30d) thin naturally.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(voip): add Gacrux to the Gemini Live voice picker
Adds the "Gacrux — Mature" prebuilt voice to the per-agent VoIP voice
selector (and the shared AgentWorkspace per-session picker, which reads
the same list). Updated in lockstep across the three mirrored sources so
the frontend↔backend parity test stays green:
- src/frontend/src/constants/voices.js — single frontend source of truth
- src/backend/config.py GEMINI_VOICE_NAMES — write-validation allowlist +
read-path fallback
- tests/unit/test_28_voip_voice_config.py — hardcoded parity tuple
Follow-up to #1323 (per-agent VoIP config panel + persisted voice).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(ui): reframe Sharing tab to external-client sharing via channels (#1347)
Part of the Access/Sharing redesign (Epic trinity-enterprise#16). The Access
tab (#17) already owns Trinity operators; this scopes the Sharing tab to the
operator → external-client surface.
- Google-Docs-style "Share this agent" framing; operator language removed
(operators live on the Access tab).
- External access policy collapsed into one **Restricted ↔ Open** segmented
control over require_email/open_access (Restricted = approval-gated, Open =
anyone verified; identity proof always on for external sharing).
- Pending requests kept, reframed as external clients awaiting approval.
- Channels rendered as compact collapsible summary rows (new
ChannelDisclosure.vue). Detailed config stays reachable inside the expanded
row as a non-regressing interim seam — #19 replaces the body with a modal
dialog. No channel functionality removed.
- Outbound file sharing + public links nudged into a separate "Distribution"
section (distribution, not client access).
Frontend-only; no API changes. SharingPanel prop/emit contract unchanged.
Verified: both SFCs compile (vue/compiler-sfc), design-token check passes.
Related to abilityai/trinity-enterprise#18
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(architecture): trim architecture.md under the 150k-char context limit (#1344)
architecture.md had grown to ~156.7k chars (on dev), past the 150k
soft limit Claude Code warns about when auto-loading it each session
(it's `@`-imported by CLAUDE.md). Over the limit the file risks silent
truncation and eats a large slice of the context window every session.
Compressed the densest Cross-Cutting Subsystem narratives, the migration
and non-root-container invariants (#3/#17), a few catalog/endpoint rows,
and the longest frontend UI prose — preferring summary + pointer where a
dedicated `feature-flows/` doc already owns the deep detail (the doc's own
editorial rule). Result: 156.7k → 149.3k chars (~4.8% smaller).
No facts dropped: every issue tag, field name, default, and error-string
is preserved; protected SQLite DDL (tracked by /validate-schema) untouched;
heading/code-fence/table counts unchanged; all 12 added flow-doc links
resolve.
Related to the over-limit warning surfaced in Claude Code.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(deps): bump js-yaml from 4.2.0 to 5.1.0 in /src/frontend (#1330)
Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.2.0 to 5.1.0.
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.2.0...5.1.0)
---
updated-dependencies:
- dependency-name: js-yaml
dependency-version: 5.1.0
dependency-type: direct:production
update-type: version-update:semver-major
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* fix(ci): guard the Alembic (Postgres) track in schema-parity (#1342) (#1345)
* fix(ci): guard the Alembic (Postgres) track in schema-parity (#1342)
The schema-parity required check validated only the SQLite track
(migrations.py ↔ schema.py). A schema change that ships the SQLite
migration but omits the Alembic revision under
src/backend/migrations/versions/ passed every required check green yet
broke PostgreSQL — init_database() runs alembic_runner.upgrade_to_head(),
which applies revision files only and does not autogenerate from
tables.py. Two PRs reached "green CI but PG-broken" and had to be held by
hand.
Add a cross-track guard, folded into the existing required schema-parity
job (no new required-check to manage):
- scripts/ci/check_alembic_parity.py — fails a PR that ADDS schema DDL to
db/{migrations,schema,tables}.py without a net-new revision file under
src/backend/migrations/versions/. Pure stdlib, PR-only (diffs base...head).
- Heuristic / false-positive guard: the signal is a DDL keyword on an
*added, non-comment* line (SQL: CREATE/ALTER/ADD COLUMN/…; SQLAlchemy:
Column(/Table(/Index(/…). Comment edits, data-only and down migrations
carry no DDL keyword, so they don't trip it. Documented in the script
docstring and the workflow header.
- tests/unit/test_alembic_parity_guard.py — 20 tests incl. the acceptance
fixtures (SQLite-only change fails; dual-tracked passes; comment/data-only
pass). Wired into the parity pytest run.
Also notes the enforcement in architecture.md Invariant #3.
Verified locally: 24 tests pass; end-to-end smoke across clean / SQLite-only
(exit 1) / paired-revision (exit 0) scenarios behaves correctly.
Related to #1342
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(ci): tighten Alembic guard to require a new MIGRATIONS entry (#1342)
Verifying against real repo history surfaced a false positive: the
migration-runner refactor #1263 (_atomic_rebuild table rebuilds) re-emits
CREATE TABLE / CREATE INDEX for *existing* tables in a rename-swap but adds
no actual schema and no new MIGRATIONS entry — yet the original "any added
DDL keyword" heuristic flagged it, violating AC #4 (non-schema edits must
not trip).
Tighten the signal to two conjuncts: a schema change must (1) register a
net-new ("name", _migrate_fn) entry in the MIGRATIONS list AND (2) carry a
DDL keyword. Runner refactors / table rebuilds add no entry → exempt;
data-only new migrations carry no DDL → exempt; real column/table adds do
both → caught.
Validated against real commits:
• #740 agent_loops, #526 agent_ownership column → FAIL (correctly blocked)
• #668 compat, voice_name (both shipped an Alembic revision) → PASS
• #1263 runner refactor → PASS (false positive fixed)
A full post-Alembic history scan finds 0 outstanding missing revisions, so
no backfill is owed; the pre-Alembic columns are already in 0001_baseline.
28 tests pass (added MIGRATIONS-entry detection + the #1263 rebuild case).
Related to #1342
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Eugene Vyborov <eugene@beingluminous.com>
* docs(dependab…
This was referenced Jul 16, 2026
7 tasks
vybe
pushed a commit
that referenced
this pull request
Jul 17, 2026
…#1676) * feat(agents): editable display label with an immutable slug (ent#181) An agent had exactly one name — its slug — so "rename" meant the heavyweight identity change: `PUT /rename` stops the container, rewrites ~20 tables, clears every per-agent Redis keyspace, renames avatar files, and STILL leaves the agent's volumes under the old base, because Docker can rename neither a volume nor its immutable `trinity.agent-name` label. That path is the root of #1664/#1665/#1667/#1669/#1671. Yet "call it Marketing Bot" is the common case, and it should not touch any of that. Adds a display label that is rendered, never resolved. The slug stays the identity everything machine-facing keys on; the label is presentation. - `agent_ownership.display_label TEXT`, nullable — NULL means "render the slug", so no backfill and every existing agent is unchanged until someone sets one; clearing reverts to the slug rather than blanking a name. Dual-track migration (Invariant #3): SQLite `agent_ownership_display_label` + Alembic 0025, plus schema.py / tables.py. - `DisplayLabelMixin` (Invariant #2 — new setting, new mixin), with a batched reader for the fleet list so the hottest endpoint stays one query, and the four facade pass-throughs (the #1666 lesson: mocked tests can't see a facade gap). - `GET`/`PUT /api/agents/{name}/label`, owner-only. Read never coerces `label` to the slug — the UI must tell "no label" from "label equals slug". WS `agent_label_changed` broadcast. - Frontend: one resolution helper (`utils/agentName.js`) — no per-site `label || name`, which would show one agent under two names (§1.3.1 FR-3). The header pencil edits the label and shows the slug as secondary text (FR-4); list/tile surfaces render the label with the slug in the tooltip. The store goes through the shared axios client (Invariant #7). - The slug rename is demoted, not removed (FR-5): a separate "Rename the id instead…" affordance with copy stating what it does (restart, re-key, volumes stay under the old id) — owners who need it keep it; it stops being the default gesture. Requirements §1.3.1 written before the code (rule #1). Verified end-to-end on the live stack: label set → slug untouched (container intact, `/api/agents/{slug}` still 200, nothing restarted), blank and null both clear back to the slug. 28 tests. Decision (maintainer): OSS-core; demote the slug rename; label everywhere. Closes trinity-enterprise#181 * fix(agents): carry the label on the detail endpoint + make the WS broadcast live (ent#181) Self-review (/review) caught two consistency gaps I'd introduced. 1. `GET /api/agents/{name}` — the endpoint AgentHeader loads on page open — enriched the agent dict with owner/is_owner/can_share but NOT display_label. So on a fresh load or refresh the header showed the SLUG; the label only appeared after an edit round-tripped through the store. Reproduced live (detail returned display_label=None for a labelled agent), fixed by adding the one read, re-verified live. §1.3.1 FR-3 — the surfaces must agree. 2. The `agent_label_changed` broadcast was dead: it set only `type`, but the frontend WS client switches on `event` (both are the convention, per agent_started et al.), and no handler existed — so a label change on one client never reached others. Added `event` + the `data` shape the other agent_* events use, and a handler that updates the cached agent (the slug never moves, so it's a pure re-render). Endpoint test updated to assert the new broadcast shape.
vybe
pushed a commit
that referenced
this pull request
Jul 23, 2026
… participation (ent#170) (#1750) * feat(ui): Sessions view — rooms rail, transcript, participants, human participation (ent#170) The operator surface for shared multi-agent sessions (backend ent#169): a top-level /sessions view where a human and several agents work one topic in a persistent transcript. A room spans multiple agents, so it is a top-level view, not an Agent Detail tab. Gated Vue in the OSS bundle (portal precedent): the route and NavBar entry only appear when `shared_sessions` is in enterprise_features, and the router guard catches a direct URL. So this ships dark in OSS/unentitled builds and lights up only once the ent#169 backend is entitled — no coupling to the submodule being present. Three panes. Rooms rail: active/closed list with a live dot, participant and message counts, and a New-session dialog that picks agents from the accessible roster and sets message/cost/TTL budgets + an optional scribe. Transcript: sender row per message with an Agent/You badge and tinted-initials avatar (the PortalAvatar idiom), portal bubble styling, @mentions of real participants highlighted, system event lines, per-agent-message metadata (execution link), and a typing-dots "working" indicator driven by participant events. Composer: @mention autocomplete over participants, the "mention wakes an agent" hint, and an optimistic append rolled back on failure. Participants rail: presence dots, budget meters that warn near exhaustion, and an expiry countdown. Live updates follow the loops pattern (#1106): the store reacts to fleet-wide room_message / room_participant_state / room_closed events for the room on screen and refetches over the access-controlled REST (thin WS payloads, the #918 pattern), plus a 12s backstop poll while any agent is working. Sends carry an Idempotency-Key (Invariant #18). All HTTP is through the shared api.js client (Invariant #7); markdown is DOMPurify'd via utils/markdown (H-005); dark mode uses the standard token pairs. Verified against the running ent#169 backend: every field the components read is present in the live /api/rooms and /api/rooms/{id} responses, and all SFCs compile. Known gaps (honest): per-message cost/duration is not shown — the room-detail response returns an execution_id per message and a room total, but not per- message cost, so the metadata line links the execution without its cost (a small ent#169 backend addition would fill it). The artifact/report card for messages referencing a shared file or report is not implemented — the backend does not surface that linkage on a message yet. Related to Abilityai/trinity-enterprise#170 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(ui): dedup room messages under back-to-back chain events (ent#170) A room chain fires several room_message WS events in quick succession (each agent turn broadcasts one, and a turn's reply wakes the next). _appendSince() is async, so two events would overlap: both read the same _seqSeen, both fetch ?since= with that value, both get the new row, and both push it — the same message and execution_id rendered twice. The 12s backstop poll compounded it. Verified frontend-only: the backend stored the message exactly once (unique seq + execution_id); the duplication was entirely in the store's append. Two guards, either sufficient alone, both kept for defence in depth: - in-flight lock: only one refetch runs at a time; an event arriving mid-flight sets a rerun flag so nothing is missed. - dedup by seq: append only rows whose seq isn't already present, so an overlapping since= window or the poll can never duplicate. Guards reset on room switch and clear() so a stale lock can't wedge the next room. Optimistic placeholders are excluded from the known-seq set (they carry a fractional seq and _optimistic), so they don't block their real row. Related to Abilityai/trinity-enterprise#170 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
dolho
added a commit
that referenced
this pull request
Jul 29, 2026
Reported from the UI: clicking **Export .xlsx** returned 401.
The buttons were `<a :href="/api/reports/{id}/export?format=xlsx" download>`,
carrying a comment that claimed "the session cookie/JWT interceptor is not
involved in a binary body". That reasoning is wrong for this platform. Trinity
holds its JWT in localStorage and attaches it via the `api.js` request
interceptor; a raw browser navigation sends no Authorization header at all, so
the endpoint correctly refused it. Export was unusable from the UI — the entire
point of #1536 — even though the endpoint itself was fine.
Why it survived verification: I tested the ENDPOINT with
`curl -H "Authorization: Bearer ..."`, which passes, instead of the BUTTON,
which is the feature. An endpoint that works under curl and 401s under a click
is exactly the gap that shape of testing cannot see.
Fix: `downloadReportExport(reportId, format)` in the reports store fetches
through the shared api client (so the interceptor runs), then hands the browser
a blob URL. Mirrors the existing `agents.js:getFilePreviewBlob` pattern and
keeps Invariant #7 (one API client, no raw fetch). The server's
Content-Disposition filename is still what names the file. Both panels
(per-agent + fleet) switch from anchors to buttons with an in-flight state, and
a failure now surfaces the backend detail — notably the #1814 503 rebuild hint —
instead of a silent no-op.
Guard: `test_1536_export_download_auth.py` — a static check, because the defect
lives in markup rather than in a callable. Asserts the anchors are gone, both
panels call the store helper, and the helper requests a blob through `api`.
Verified it fails on the pre-fix markup (5 failed) and passes after.
Verified in a real browser, clicking the real buttons: both formats download
200 with the server-supplied filenames (362 KB xlsx / 153 KB pdf), no alert.
Related to #1536
vybe
pushed a commit
that referenced
this pull request
Jul 29, 2026
…ab (ent#235) (#1877) * feat(ui): Skills management surface — unhide and rebuild the Skills tab (ent#235) The skills machinery shipped across three planes (#182 distribute/place/expose, #183 package injection with a per-skill result contract) and nothing rendered any of it. The Agent Detail Skills tab was excluded from `visibleTabs` per requirements §22.2 ("component preserved for potential admin-only access"), assignment was REST/MCP-only (§21.3), and #183's statuses and named warnings had no consumer at all. A user could not browse the library, see what an agent had, or assign anything. What lands: * **Tab unhidden** for owners/admins on non-system agents, matching the other management tabs. `OverflowTabs` absorbs it. * **`stores/skills.js`** — a domain store (Invariant #6). The old panel called `axios` directly with a hand-built auth header, silently bypassing the shared client every other call relies on; everything now goes through `api` (Invariant #7). * **Library browse** with the §21.6 contract surfaced: description, automation, `user_invocable`, declared `requires` (binaries/packages/env), multi-file file count, size, and the git tree SHA as version. Dependencies are shown BEFORE assignment, because they are what later becomes a `missing_binary:*` warning. * **Assignment** with bulk save through the existing `PUT .../skills`, plus a dirty/reset affordance so a half-made selection is recoverable. * **Honest injection status.** This is the load-bearing part. #183 reports `injected | unchanged | fallback | failed` with named warnings; the panel renders the verdict per skill and translates the tokens into what they mean for THIS agent ("`jq` is not installed in this agent — the skill may not run"). `fallback` renders as "partial" in amber, never a green tick — an explicit AC. Injection results are kept separate from assignment in the store precisely so a durable assignment cannot be painted with a stale success. * **Manual sync** (`force=True` repair action) with in-flight state, and a 409 from `SkillInjectionBusy` reported as "already running" rather than a generic failure. * **No dead empty states** — the store computes one discriminator (`library_unconfigured` / `library_empty` / `none_assigned`) so the panel cannot invent a fourth. Unconfigured routes an admin to Settings and tells a non-admin to ask one. * **Stopped agent** renders persisted assignment state with Sync disabled and the reason in the tooltip, rather than offering an action that would fail. Verified against the live instance: tab appears, 3-skill library renders with contract fields, bulk assign persists, agent started, "Sync now" returns `{haiku: injected/2 files, word-count: injected/2 files}` and the badges + last-sync line render from that response. Gating confirmed OSS-core with the issue author before building: every file here is already public, the endpoints are ungated, and the paid piece (skill_runner, ent#139) plus exposure curation (#178) are both explicitly out of scope. Related to trinity-enterprise#235 * fix(ui): dead Settings link + error swallowed as an empty library (ent#235 review) Self-review of #1877 found two defects, both in the "no dead empty states" AC this panel exists to satisfy. 1) The "Configure the library" CTA linked to `/settings?tab=skills`. There is no such tab — the Skills Library config lives under Settings → **agents** (`Settings.vue`, `v-if="activeTab === 'agents'"`). So the one call-to-action offered to an admin staring at an unconfigured library went nowhere. I also asserted in the PR body that the Settings panel already reported sync status / last-synced / skill count without checking; it does report all three — but I had the tab wrong, which is what checking would have caught. 2) `api.get('/api/skills/library').catch(() => ({ data: [] }))` swallowed every error, not just the unconfigured case: a 500, a timeout or an auth failure all rendered as "the library is configured but has no skills yet" — a confident, wrong empty state that points the operator at the wrong problem. The list is now fetched only when `status.configured` is true, so the known empty state comes from the status read and any other failure surfaces as one. Related to trinity-enterprise#235
3 tasks
vybe
pushed a commit
that referenced
this pull request
Jul 31, 2026
…ect (ent#263) - Library.vue: h1 'Library', new subtitle, templates content wrapped in an 'Agent Templates' section (own loading/error/empty states; inner headings demoted h2->h3); fetch migrated to the shared api client (Invariant #7) - router: /library route (meta.title Library) + /templates function-form redirect carrying query AND hash; route name Templates->Library (no named pushes exist) - NavBar: label Library, to=/library, active via startsWith('/library') - CreateAgentModal: its single raw-axios /api/templates call migrated to the shared api client so no half-migrated consumer of the endpoint remains Page-identity naming only (AC#4 reading): the asset-kind noun 'template' survives inside the Library (Starter/GitHub Templates sections, Use Template). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
AndriiPasternak31
added a commit
that referenced
this pull request
Jul 31, 2026
ent#263 (PR #1904, merged today) renamed `Templates.vue` -> `Library.vue` and restructured it into "installable assets for your fleet" — two stacked sections with jump anchors — with a comment recording that tab/filter-pill navigation was considered and deliberately rejected for this page. ent#126 had built its install surface as an `?tab=agents|systems` strip on that same page. Two independent designs for one page. This conforms to ent#263's model rather than reintroducing a competing one: Systems becomes a third `<section id="systems">` with its own jump anchor. Placed BETWEEN Agent Templates and Skills, not appended: Systems and Agent Templates both *install agents* (one template makes one agent, one manifest makes a wired fleet), while Skills configures agents that already exist. Grouping the two install kinds and leaving Skills last reads better than stacking Systems third by default. Hidden outright below `creator` rather than shown-and-disabled — a browse surface gains nothing from a dead panel — and the jump anchor is gated with it, so the nav can never point at a section that is not rendered. Also in this merge: - Kept dev's shared `api` client; did NOT reintroduce the raw `axios` import my side still carried (Invariant #7 moved underneath this branch). - e2e retargeted from the tab strip to the section, plus a new assertion that `/templates#systems` still redirects with the hash intact — that legacy hop is what older links land on. - Docs realigned across architecture, requirements, both flows and the user guide; the ent#263 page flow (`library-page.md`) gains the third section and records that tabs stay rejected. - learnings.md and the feature-flows index took both sides. The design question this raises — whether a fleet installer belongs on a browse page at all — is deliberately left for review rather than settled here. Full unit suite on the merged tree: 6173 passed, 14 skipped, 1 xfailed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
vybe
pushed a commit
that referenced
this pull request
Aug 3, 2026
…ty-enterprise#127) (#1948) * fix(agents): stop a malformed `credentials:` also costing runtime + shared_folders `_resolve_local_template` read `creds.get("mcp_servers", {}).keys()` straight through the block. A null / list / string `credentials:` raises AttributeError there, and that read sits FIRST in a run of `config` mutations wrapped in one broad `except Exception` — so the failure skipped every mutation after it. A single malformed key therefore silently cost the agent its `runtime:` (wrong harness) and its `shared_folders:` config too, with only a WARNING to show for it. Reads through PR-A's tolerant `credential_mcp_server_names()` instead, so the credential parse degrades on its own and the unrelated settings survive. The five malformed shapes are pinned as parametrized regressions; all five fail on the pre-fix code. Refs Abilityai/trinity-enterprise#128 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(agent-server): tolerant `credentials:` read on GET /api/template/info Same uncaught reach-through PR-A fixed on the backend, still live on the agent image: `.get("credentials", {}).get("mcp_servers", {}).keys()` raises AttributeError on a null / list / string block at EITHER level, and the endpoint's own `try/except` wraps only the YAML load — so the crash escaped as a 500 on the Info tab and the brain-orb route guard. `template.yaml` here is read from the agent's own workspace, which the agent itself can rewrite, so this is reachable without an operator touching anything. The agent server ships in its own image and structurally cannot import `src/backend`, so the reader is DUPLICATED, not imported. The two in-repo precedents for that (`credential_paths.py`, `model_context.py`) are vendored byte-identically WITH a parity test; a 6-line reader does not earn a whole vendored module, but it does earn the same guard — before this commit NO parity test covered `agent_server/routers/info.py`, so the copies could diverge freely. Added in the `test_1713_scheduler_utils_parity.py` shape: one shared 17-row table of malformed shapes driven through BOTH implementations, asserting agreement on OUTPUT (the copies are textually divergent by design, so a source diff cannot verify them). Also routes the endpoint through the existing `get_template_path()` helper — `/api/metrics` already does — instead of a second copy of the path literal, so the regression is testable without patching `Path`. This is the change that makes `/verify-local` mandatory WITHOUT `--skip-agent`. Refs Abilityai/trinity-enterprise#128 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(compatibility): a credential detector must not read narrower than it audits K-001 (HARD) compared `.mcp.json.template`'s `${VAR}` references against an UPPERCASE-ONLY view of `.env.example`. Trinity's substitution engines impose no charset at all — the agent-side writer is a `str.replace` and the `.env` writer slices `env_val[2:-1]` — so `${my_var}` IS substituted at runtime, and a template that correctly documents `my_var=` was HARD-failed for a gap that does not exist. `services/credential_charset.py` is the one place that decision now lives, named for its ROLE (`CREDENTIAL_DETECTOR_CHARSET` — "the widest charset a detector must accept so it is never narrower than the engine it audits"), not for a reach it does not have. Four detectors adopt it; the docstring carries an explicit NON-MEMBERS list with a reason per entry, because the previous framing ("the charset every Trinity surface agrees on") is false and reads as an instruction to the next engineer who greps `[A-Z][A-Z0-9_]*`: * `mcp_validator._ENV_VAR_REF_RE` is a FAIL-CLOSED gate (`.mcp.json` inject → 400, `.credentials.enc` import, deploy-local), deliberately paired with the WIDEST finder (`[^}]*`). Widening it admits input that is currently rejected. * `skill_packaging.ENV_KEY_RE` is an adjacent domain with its own length cap. * `static_checks._ASSIGN_RE` carries the quantifier shape behind an already-FIXED py/polynomial-redos alert, on an agent-supplied-text path. * `c_d006` is a different vocabulary that merely looks similar. The constant lives in a pure-stdlib leaf module, NOT in `services/compatibility/`: that package's `__init__` imports `database`, and `static_checks` imports `template_service`, so a `template_service` → compatibility edge is a hard cycle (reproduced: "cannot import name '_is_platform_injected' from partially initialized module"). Behaviour changes, both named: * K-001 (HARD) `fail → pass` for a documented lowercase variable — the fix. * K-003 (SOFT) `pass → fail` for a lowercase-only, comment-free `.env.example`. `_env_example_vars` is K-003's precondition for DEMANDING comments, so growing it makes the verdict worse. The verdict is correct — that file genuinely has no comments — but it is a `pass → fail` and is release-noted, not smuggled. * S-010 (SOFT) does NOT flip: its `generic` blocklist is uppercase-exact, so no newly-visible lowercase name can join it. Asserted, not assumed — it is safe by coincidence of casing. The two `template_service` extractors are included because both feed the LIVE `collect_mcp_credential_warnings` → `deploy.py` path; leaving them out would half-fix the very inconsistency this closes while a four-way agreement test passed. Direction there is FEWER spurious warnings. `test_deploy_local_validation.py` (the 8-assertion suite on that path) stays green. Also hardens two latent crashes in the same file: a null / non-mapping `template.yaml` document reaching `extract_credentials_from_template_yaml`, and `extract_agent_credentials` reaching through `credentials:` at three levels. New `credential_mcp_env_vars()` reader returns non-empty strings only, so an `env_vars` element smuggled in as a mapping can never reach a consumer — that element is exactly what turns a set comprehension into `TypeError: unhashable type: 'dict'`. Refs Abilityai/trinity-enterprise#128 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(compatibility): K-002 compared ${VAR}s against section names, and could go dark Two defects in the same HARD gate, one of them a way for the gate to stop protecting entirely. **1. It read the structure, not the declaration.** `listed` was `set(creds.keys())` — `{"mcp_servers", "env_file"}` — so the documented structured form `credentials.mcp_servers.stripe.env_vars: [STRIPE_API_KEY]` satisfied nothing and HARD-failed a correctly declared template, while `${env_file}` and `${mcp_servers}` PASSED. The admitted set was "whichever section names this template happens to use", so the blind spot was template-dependent — the worst kind, because it cannot be found by reading the check. `declared_credential_names()` (the union of `mcp_servers.*.env_vars` and `env_file`, over PR-A's tolerant readers) is now unioned in, and the three known STRUCTURE keys are subtracted. A flat `credentials: {STRIPE_API_KEY: '...'}` mapping is still admitted — that legacy shape is legitimate and keeps passing. The section subtraction is a deliberate `pass → fail` for a genuinely broken template. Shipped named, tested and release-noted, NOT smuggled under a monotonicity claim: the blanket "strictly monotone, fail→pass only" claim is false and a reviewer would find the counter-examples. **2. It could go dark.** `run_static` caught `Exception` → `skipped`, and `_counts` counted only `status == "fail"`, so a raise inside a HARD check DROPPED `hard_count` and could flip `overall_status` from `issues` to `compatible` on an agent with a genuinely undeclared credential. `c_k002` delegates to `c_t015`, so ONE raise took both HARD gates dark together, and the result is indistinguishable from a clean pass in the counts. The trigger is four lines of untrusted YAML: credentials: mcp_servers: s: env_vars: - {STRIPE_SECRET_KEY: "please"} `template.yaml` here is read from a live agent workspace, whose git repo the agent itself owns — a self-attestation bypass on the surface whose job is to police it. The same `TypeError: unhashable type: 'dict'` is the failure mode that argued against enriching `credentials.env_file` in the first place, so reintroducing it at the new call site would have been the plan diagnosing a bug and then shipping it. Three layers, deliberately: * `c_t015` wraps ONLY the new term and degrades to the narrower set — which makes `missing` LARGER, i.e. errs toward failing — never to `skipped`. * `run_static` returns FAIL for a check that raises. A check that could not evaluate is not a check that passed; one bad check still never breaks the report. * `_counts` also counts `skipped` + `skip_reason == "check_error"` as a finding, at the sink (#1525), so the property survives a future path reintroducing the skip. A benign precondition skip (`no_template`, `ai_not_run`) still counts as nothing — that distinction is why the skip path exists. `declared_credential_names` guarantees `str` elements structurally, and the call site filters `isinstance(name, str)` anyway: the gate must not depend on the reader's contract holding. Refs Abilityai/trinity-enterprise#128 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(templates): MCP-server precedence, and a credentials badge that counts Three catalog defects PR-A deferred, all in the two builders. **Defect D — precedence was backwards.** `_build_local_template` read `credential_mcp_server_names(credentials_block) or data.get("mcp_servers", [])`, so a `credentials:` block silently OUTRANKED the template's own `mcp_servers:` declaration. `agent_server/routers/info.py` has always read them in the other order, so the catalog and the agent's own Info tab disagreed for any template declaring both. Operands flipped; the `credentials:` path stays as the fallback. **W14 — the GitHub builder had no fallback at all**, so a GitHub template declaring only `credentials.mcp_servers` showed an empty list in the catalog while its Info tab listed them. That was the third of three surfaces; all three now agree. **Defect C / W6 — the badge.** Both builders read a flat top-level `required_credentials:` key that ZERO templates declare — 25 bundled and all 7 configured GitHub repos — so `Templates.vue` rendered 0 for everything. Now derived from the declared base set, with `platform_injected` vars EXCLUDED. That exclusion is the badge's semantic, and it is not cosmetic: measured on the real shipped catalog, a naive derivation is correct on 1 of 7 repos and wrong in both directions — the ent#124 first-run agent would read 5 where the operator supplies 2, while three shipped repos stay at 0. The chip is read as "how much work is this to set up", so counting `GEMINI_API_KEY` / `GITHUB_PAT` / `TRINITY_*` inflates it with rows nobody can fill. A consumer that wants every declared variable wants `declared_credential_names`, not this. Derived unconditionally rather than "explicit key wins, else derive": that override branch is unreachable (no template declares the key), so keeping it would be one dead code path guarding a live one. No frontend change: `Templates.vue:103,107,171,175` read only `.length`, so the shape it already expects is preserved and a variable name never reaches the DOM. Refs Abilityai/trinity-enterprise#128 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(templates): `credential_setup:` — per-variable credential setup metadata Closes ent#128 AC #1-2. A template can now describe each credential an operator must supply — title, description, required, secret, format, setup_url, default — and `template_service` surfaces the normalized result as `credential_requirements` on every catalog entry. **Enrichment lives in a NEW sibling top-level key; `credentials:` is FROZEN as names-only, forever.** An already-deployed older Trinity reads `env_file` through `credential_env_file_names` and then does `agent_credentials.get(var_name, "")` — hand it a list of mappings and that is `TypeError: unhashable type: 'dict'` at the moment it writes the agent's `.env`. A sibling key is structurally invisible to that binary, so there is no floor version and enrichment distributes immediately. **Base-set-plus-overlay, so the two keys cannot drift.** One record per variable `credentials:` declares, decorated by `credential_setup:` entries joined BY NAME. An entry naming nothing is a named three-line error (problem, cause, FIX) and is dropped; valid siblings survive. `credential_setup:` can only ever decorate — the sibling-key shape's usual failure mode is closed by construction, not by discipline. Stated honestly: for an EXTERNAL template that error is neither impossible nor visible in the UI — `credential_errors` has zero frontend and zero MCP consumers, so the only human channel is the backend log. It is LOGGED. `required` is a tri-state. Enriched-and-omitted means `True` (an author who described a variable meant it); a legacy bare `- FOO` is `"unknown"`, never `True` — it carries no authorial intent, and reading it as required makes a guided checklist cry wolf. `"unknown"` doubles as the enriched/un-enriched discriminator, which is why no `enriched: false` flag is needed. `secret` defaults `True` (fail-safe). Path-free by construction, so trinity#570's `template.yaml` → `trinity.yaml` rename cannot reach it. **The normalizer never raises, and that is load-bearing.** `_build_template` runs in bare list comprehensions in `get_all_templates()`, OUTSIDE PR-A's per-template fence (which covers `_build_local_template` only) — a raise there is HTTP 500 with an EMPTY CATALOG, i.e. PR-A's exact bug reopened by the change that surfaces the new metadata. And no bomb is needed: `title: 123` or a bare `title:` was enough. So the builders ALSO wrap the call and degrade to `[]` plus a named error, rather than fencing the comprehension — that keeps the named error the resilience contract promises. The property does not rest on one function's discipline. (Which earned its keep immediately: the wrapper caught a real NameError during development instead of emptying the catalog.) Trust boundary — `title`/`description`/`setup_url`/`name`/`source` are author-controlled strings from arbitrary GitHub repos flowing into an operator-facing "paste your API key" checklist: * **Type-guard before touching.** Never `str()` a container from untrusted YAML: `str()` EXPANDS a shared alias during the walk (443 B → 52 MB in 1.5 s, x10 per level), and both the sanitizer and the record cap act after that cost is paid. * **Cap the INPUT**, entries AND errors AND the base set. Capping records while leaving `errors` uncapped built a 35 MB response out of the cap meant to prevent it; and `default` had no type row, so the 100-record cap acted as a x100 multiplier on it. * **`source` is sanitized** — it carries the raw MCP server name, the exact string `_sanitize_for_warning`'s own docstring names as the threat, and it was not on the list. * **Per-field length caps.** Reusing the 80-char terminal-warning default truncated a realistic 159-char description and made a real 90-char vendor console URL unusable. * **`setup_url` above scheme-only**: https (case-insensitive — `HTTPS://` is a legitimate author), a parseable host, NO userinfo (`https://google.com@evil.tld` renders as one host and resolves to another — the display/resolve split IS the attack), ≤2048, printable. Validate THEN sanitize, and never through a truncator. Residual documented, not claimed closed: `isprintable()` rejects RTL/ANSI but an IDN homograph survives, so a consumer must render the parsed hostname beside the link. * **Never mutates its input** — `_metadata_cache` holds the parsed dict for 600 s and YAML aliases genuinely share nodes, so one in-place normalize would rewrite both aliased fields and persist for ten minutes. Asserted against a deep-copy snapshot, including a real `&anchor`/`*alias` document. `credential_shape_errors` also gains the per-server and per-ELEMENT rows for `mcp_servers`, mirroring what `env_file` already had. The element row is the one that matters — an `env_vars` entry smuggled in as a mapping was the single most dangerous shape in the block and was unnamed. Note this makes the write path (`generate_credential_files` → 400) reject a template that previously created an agent with a garbage declaration: correct per PR-A's fail-loud write contract, and release-noted. `generate_credential_files` is deliberately UNTOUCHED — it still reads `env_file` names-only, which is what makes the forward-compatibility argument true rather than asserted. Refs Abilityai/trinity-enterprise#128 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(schemas): trinity-agent-credentials.schema.json — the declaration contract Closes ent#128 AC #3's machine-readable half. Follows the established `docs/schemas/` convention (`agent-pipeline.schema.json`): Draft 2020-12, date-stamped `$id` so a future revision keeps answering for templates written against this one, and self-described as the authoritative documentation contract while the backend reader stays deliberately tolerant. **Rooted at `template.yaml`, not at `credentials:`.** The two keys are ONE contract joined by a mandatory cross-reference, and validating either alone cannot check it. **`additionalProperties: true` at the root and on `credentials`** — template.yaml carries many keys this schema deliberately says nothing about, and a template predating the schema must stay VALID. Accepted asymmetry, and it is asserted as a test rather than left as a surprise: a made-up top-level key IS valid here. **`config_files` is enumerated and `deprecated: true`, not omitted.** The earlier posture was "don't delete, don't advertise", which made the authoritative contract answer VALID to `path: "/etc/cron.d/pwn"`. Undocumented is not a control against an author who knows the key — only against the reviewer who doesn't. So it is documented as deprecated, with a containment `pattern` that rejects absolute and `..` paths and a description saying plainly that it writes files into the agent's credential directory. Still reversible, still invalidates nobody. (Whether to DELETE the key is a public behaviour change and stays @vybe's call.) Carries the A2 consumer requirements in `$comment`, because the schema is the artifact a downstream implementer reads: * a record with `required: "unknown"` carries no authorial intent and MUST NOT be presented as a required field — without this a naive UI renders a seeded agent as five mandatory rows, three of them platform variables nobody can fill; * `platform_injected: true` MUST NOT be asked of an operator; * `secret: true` (the default) MUST be masked; * `setup_url` MUST be rendered with its parsed hostname shown, because the IDN homograph residual is real and documented rather than claimed closed; * there is intentionally NO reverse cross-reference requirement — a declared variable with no `credential_setup:` entry is normal. Also states the author cost honestly in the authoring note: declaring in `credentials:` is a separate edit from referencing `${VAR}` in `.mcp.json.template`, K-002 checks the two agree, and that is deliberate because `.mcp.json.template` must not become a second declaration authority. Plus the two brace forms Trinity's readers cannot see (`${my-key}`, `${VAR:-default}`). Tests pin the schema against the implementation — field caps, the format vocabulary, the allowed-key set, the record cap — so the reviewed text and the enforced text cannot drift. The 13 document cases run under `importorskip` (`jsonschema` is not a declared Trinity dependency); the security-relevant pattern assertions are unconditional. Refs Abilityai/trinity-enterprise#128 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(templates): the Trinity-installable credential contract + reference examples Closes ent#128 AC #3-4. **Reference examples (AC #4).** The substrate the original plan targeted is gone — `3317247e` deleted `config/agent-templates/cornelius/` in favour of seeding from the public upstream repo — so AC #4 lands on what the bundle actually has: * `scout` / `sage` / `scribe` (the ent#124 seeded trio) declare an explicit `credentials: {}` with the zero-credential contract written out. Absent and empty mean the same thing to Trinity, but *absent* is ambiguous to a HUMAN — it could equally mean the author forgot. `{}` says "considered, and there are none", so the catalog's 0-credential badge is trustworthy. * `test-codex` carries the enriched reference: its one real variable gets a title, description, `required`, `secret`, `format` and `setup_url`. Deliberately NO `GEMINI_API_KEY` in any example: it is platform-injected, so an example asking for it would violate the very rule the guide documents — and it makes a K-002 fixture pass VACUOUSLY, which is how a test proves nothing while looking green. A test asserts no bundled example asks for a platform-injected var. Framed honestly rather than oversold: with one enriched declaration and one names-only one in the bundle, the parity test ("every bundled template normalizes with zero errors") is thin today. Its value is as a RATCHET for ent#137's curated fleet. **The guide (AC #3).** New `## Declaring Credentials` section, TOC renumbered 5→21. Covers the field table, the decorate-don't-declare rule with the actual error text, why `credentials:` stays names-only, the zero-credential contract, degrade-don't-demand, the platform-injected list, fork-to-own composition (ent#109), and the two brace forms Trinity's readers cannot see (`${my-key}` silently dropped, `${VAR:-default}` mis-substituted to an empty string). It also states the AUTHOR COST plainly instead of claiming the design is free: declaring a variable is a separate edit from referencing it in `.mcp.json.template`, and three of Trinity's own six default GitHub templates declare zero credentials while referencing 2-6 and documenting 7-12. Those are K-002-red today and stay red until someone does the edit. Kept that way on purpose — if `.mcp.json.template` counted as a declaration it would become a second authority on what an agent needs, which is the drift this design exists to prevent. The practical order is stated: seed `credentials:` first, enrich second. **Memory docs.** `requirements/credentials.md` §3.5's ✅ was false in both halves and is corrected in place with the correction recorded: the extractor it credited has no production caller, and nothing showed configured-vs-missing status because the badge read a key no template defines. `template-processing.md` and `templates-page.md` get the "two shapes, two owners" table that reconciles the objects-vs-strings contradiction (catalog `required_credentials` = names, `credential_requirements` = objects, extractor `required_credentials` = a different function with the same key name), plus the corrected regex. Compatibility checklist gains six credential rows and a starts-with-nothing-configured row. **No DB change → Rule #9 (dual-track migration) does not apply.** Refs Abilityai/trinity-enterprise#128 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(templates): close the ent#128 coverage gaps the gate surfaced A transition diff over a corpus with zero coverage of the diff is not evidence, so the changed statements were measured against PR-B's real base (`c07afab7` = origin/dev + PR-A) rather than assumed. The gate found the new paths that no test reached and this closes them: * §4's new `mcp_servers` shape-error rows — per-server AND per-element, six parametrized cases plus the sanitized-server-name case. The element row is the dangerous one and it had no test. * The write-path consequence, asserted explicitly: `generate_credential_files` now raises on `env_vars: [{K: v}]`, where before it created the agent silently. * `_setup_url_error`'s `urlsplit` ValueError branch (malformed IPv6 literal). * The dedup early-return in the base-record builder — a variable declared under two servers AND `env_file` yields one record with a stable `source`. * A non-string mapping key in a descriptor (`{1: "x"}`), which must not reach the "did you mean" helper. * The caller-less `extract_agent_credentials` across eight malformed shapes. It has no production caller, which makes hardening cheap rather than unnecessary — the next caller would have inherited the crashes. Now exercised instead of merely present. Result: 227 changed statements, 225 executed. The two remaining are a defensive `except OSError` around a `Path.resolve()`, and the three gate files (`static_checks.py`, `compatibility/__init__.py`, `credential_charset.py`) are at 100% of changed statements. Refs Abilityai/trinity-enterprise#128 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(feature-flows): sync the compatibility flow + index for ent#128 `/sync-feature-flows`. `template-processing.md` and `templates-page.md` were already updated with the declaration standard; this adds the flow the code change actually lands hardest on and which nothing had touched: `agent-compatibility-validation.md`. Both credential HARD gates changed, and the flow doc described neither the defect nor the new semantics: * "a detector must never read narrower than the mechanism it audits" — the shared root cause of K-001 and K-002/T-015, with the NON-MEMBERS list spelled out so the next reader does not "align all the regexes" and widen `mcp_validator._ENV_VAR_REF_RE`, which is a fail-closed GATE and not a detector; * "a HARD gate must not be able to go dark" — the `run_static` →`skipped` + `_counts`-counts-only-`fail` interaction that let 4 lines of untrusted YAML drop `hard_count` 1→0, and the three fail-closed layers that replace it; * the complete verdict-transition set, because the blanket "strictly monotone" claim is false and a reader will find K-003's `pass→fail`. The claim that survives is "no agent gains a HARD failure". Testing section records why the bundled templates cannot prove any of this — 0 `.mcp.json.template` and 0 `.env.example` files, so every changed check short-circuits before reaching changed code and a green diff there is green-because-vacuous — and points at the 49-fixture synthetic corpus instead. Plus the Recent Updates row in `feature-flows.md` (the step this skill's own docs warn gets skipped). Observation, deliberately NOT fixed here: the Recent Updates table carries 57 rows against its own documented "newest ~20" cap (#1360), so the index is 434 lines vs the 400-line guideline. Trimming it means deleting 37 other engineers' entries, which is not this PR's call. Refs Abilityai/trinity-enterprise#128 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(templates): use an unambiguous placeholder credential value `sk-live-xxx` is stripe-shaped and gitleaks' default ruleset covers `sk-`. The value is arbitrary in this test — it only has to round-trip byte-identically through the `.env` writer — so there is no reason to hand CI a secret-shaped string to reason about. Refs Abilityai/trinity-enterprise#128 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(agents): teach the crud harnesses the tolerant credentials accessor Real regression I introduced in `886aab5b` and initially mis-attributed as pre-existing. Recording both the fix and how the mis-attribution happened, because the second part is the reusable lesson. **The bug.** `test_1484_create_agent_characterization.py` and `test_1759_local_template_not_found.py` MagicMock the whole `services.template_service` module and stub each function crud actually calls with a faithful return value (`generate_credential_files` → `{}`, `get_github_template` → `None`). `_resolve_local_template` now calls a THIRD one — `credential_mcp_server_names` — and it was unstubbed, so it returned a truthy Mock that passed `if mcp_servers:`, landed in `config.mcp_servers`, and blew up later inside a `yaml.dump` as `ValueError: dictionary update sequence element #0 has length 1; 2 is required`. 18 tests, entirely a harness gap: in production the real function returns a list. Stubbed with a faithful 3-line mirror rather than a fixed `[]`, so a fixture that DOES declare `credentials:` cannot be silently masked by the stub. **One test needed a real update, not a stub.** `test_malformed_field_still_creates_and_names_the_template` used `credentials: "a string"` as its trigger for the broad-except degrade path. That is exactly what `886aab5b` fixes — `credentials:` is no longer a trigger BY DESIGN, because it raised FIRST in that run of mutations and so cost the agent its `runtime:` and `shared_folders:` config as collateral. Swapped the trigger to `shared_folders: not-a-mapping`, which still raises, so the degrade path and the two identifiers in its warning stay under test. The docstring records why and points at the new coverage. **How I mis-attributed it.** I compared with `git stash push -- src/backend`, which reverts only the WORKING TREE — commits 1 and 2 were already committed, so my "baseline" still contained the cause and the failures looked identical on both sides. The `-k`-filtered selection also happened to include only 1 of the 13 `test_1484` failures, which made the set look small and stable. Only a worktree at `c07afab7` (PR-A's tip, PR-B absent) showed the truth: 2 failures there vs 20 on the branch. **A baseline has to be a worktree at the base commit, not a stash.** Now identical to `origin/dev` and to `c07afab7`: 2 failures, both genuinely pre-existing (`test_agent_analytics::test_day_stacks_present_in_by_type`, `test_1069_voip_call_path_param` — the documented `get_flat_dependant` venv drift). Refs Abilityai/trinity-enterprise#128 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(templates): the tolerant credential reader must not blow up or raise Two holes in the "never raises, never amplifies" property PR-B rests on, both found by asking which OTHER producers reach the surface the new cap protects. 1. `credential_shape_errors` was uncapped. The cap shipped on the NEW function (`normalize_credential_requirements`), but the same PR added a per-ELEMENT loop to this PRE-EXISTING one, and it feeds the same two surfaces: the catalog's `credential_errors`, and the `"; ".join(errors)` that becomes `CredentialDeclarationError`'s agent-creation 400 body. A cap is a property of the producer, not of the PR that invented the concept. YAML anchors make input size a useless proxy for output size, so the bound has to stop the WALK, not slice the result. Measured on a 6,738-byte `template.yaml` (one 200-element anchor aliased across 200 servers): 40,000 errors / 3.64 MB joined (540x) before, 101 errors / 8,973 bytes after. `origin/dev` returns 0 on the same input, so the amplification is this branch's own — reachable since ent#123 by any creator-role user pointing at an arbitrary public repo. 2. `source_trust not in _SOURCE_TRUST_LEVELS` is frozenset membership, so an UNHASHABLE value raised `TypeError` *on the guard line* — before the degrade-to-`github` branch that guard exists to reach. Unreachable from parsed YAML today (every call site passes a literal), but this is the one function whose docstring makes "NEVER RAISES" load-bearing: a raise here is an empty catalog and a dark HARD gate. The property should be literally true, not true-by-call-site-audit. Both regression tests were confirmed to FAIL with their fix reverted and pass with it restored. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * build(tests): cap fastapi to prod's 0.115.x line The unit suite was validating against a FastAPI ~25 minor versions ahead of the one production ships. `docker/backend/Dockerfile` pins `fastapi==0.115.6` exactly; `tests/requirements-test.txt` carried an unbounded floor that resolved 0.140.13. The comment at :43 already claimed these "match the floors set in docker/backend/Dockerfile" — that file uses exact pins, so the claim was untrue. Surfaced as `test_1069_voip_call_path_param` failing with `ImportError: cannot import name 'get_flat_dependant'`. That test is only the messenger: it is the one test coupled to a private FastAPI symbol (`src/backend` imports none, and the other test touching `fastapi.routing` uses the public `APIRoute`). The obvious ceiling does not work: `0.140.13 < 0.141` is true, so `<0.141` still admits the breaking version. Bisected against the real wheels — present in 0.140.6, gone in 0.140.7 — a private API dropped in a PATCH release, so no minor-level bound is trustworthy. Tracking prod's line is the durable fix. Why now rather than "separate follow-up": CI is green only on a warm pip cache. backend-unit-test.yml keys `cache-dependency-path` on this file, and 0.140.13 allows py3.11, so the next edit to this file for ANY reason busts the key, re-resolves, and breaks CI for everyone. Capping is the safe way to bust that cache — the change that invalidates the key is the one that makes re-resolution correct. Follows this file's own precedent (`bcrypt>=4.2.0,<5`, added when bcrypt 5.0.0 removed the `__about__` shim passlib reads): floor + ceiling + a comment saying why, rather than an exact pin that would break the file's `>=` convention. Verified by execution, not argument: - full tests/unit at 0.115.14: 5861 passed, 16 skipped, 2 xfailed, 0 failed (at 0.140.13 the same command is 1 failed, 5860 passed) - the edited file installs clean in a fresh venv and resolves 0.115.14 - an existing verify venv self-heals: pip downgrades 0.140.13 -> 0.115.14 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(templates): take the trust label from the caller, not a tainted path `_build_local_template` derived `is_bundled` itself: is_bundled = template_dir.resolve().parent == _local_templates_dir().resolve() `template_dir` on the by-id path is `_local_templates_dir() / name` where `name` comes from a user-supplied `local:<name>` template id, so this called `.resolve()` on attacker-influenced input. CodeQL flagged it as `py/path-injection` (alert 260, high) — a new tainted-path sink introduced by ent#128 purely to pick a log level (`source_trust` selects `logger.warning` vs `logger.info` and nothing else). `is_bundled` is now a required keyword arg supplied by whoever knows the provenance: - `get_local_templates()` iterates the curated root, so its children are bundled by construction -> `is_bundled=True`. - `get_local_template()` decides from the id STRING (plain single segment, no separator, not a dot-segment) rather than a path operation on it. Behaviour, measured against the old predicate across 9 ids: 7 identical, 2 divergent — `'../agent-templates/sage'` and `'a\b'` go True -> False. Both moves are old=True -> new=False, i.e. strictly more conservative: the new check never grants the `bundled` label where the old one withheld it, only the reverse. An id that traverses to arrive inside the curated root is not curated, so the new answer is also the more correct one; the blast radius either way is one log level. This is deliberately NOT a traversal guard — and as of the 2026-08-02 rebase it no longer needs to be. An earlier version of this message said the traversal was "being routed as its own issue rather than fixed"; that issue, #1900, has since been fixed on `dev` by #1935, which this branch is now rebased onto. `get_local_template` therefore routes `name` through `contained_template_dir()` — a name allowlist plus resolve + `is_relative_to` — BEFORE the label check runs. (The traversal was real while it lasted: `local:..` escaped the templates dir, reachable by any authenticated user via `GET /api/templates/{id:path}`.) That makes the `is_plain_segment` check redundant today — provably True wherever it is reached, since the barrier above rejects every non-plain name first. It is kept as defence in depth: it decides a trust LABEL, and `contained_template_dir` is a shared primitive the remote-template-registry work (trinity-enterprise#14) is expected to edit. A label that silently became `bundled` if that barrier were ever widened is the exact failure this keyword argument exists to prevent. It re-adds no tainted-path sink — it reads the id string, never the filesystem. Two test call sites updated for the new signature. Verified on the rebased branch: full tests/unit 6410 passed / 16 skipped / 0 failed; no `template_dir.resolve()` remains in the module. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(credentials): canonicalise setup_url hosts the way a browser does `credential_setup[].setup_url` is author-controlled and lands beside a "paste your API key here" input. `_setup_url_error` rejects the `user@host` form but its own docstring records the residual it does NOT close — IDN homographs survive, so "a consumer MUST render the parsed hostname next to the link". ent#127 is that consumer and the field's first renderer. `services/setup_url_display.describe_setup_url` is that half. Three properties, each load-bearing: - UTS-46 nontransitional via the `idna` package, NOT `str.encode("idna")`. The stdlib codec is IDNA2003 and disagrees with every browser on exactly the deviation set that matters: `faß.de` encodes to `fass.de` where a browser resolves `xn--fa-hia.de` — a different registrable domain. A mitigation that displays a domain the click does not resolve manufactures the very split it exists to close. - Fails CLOSED. Every failure path returns `display_host is None`, which the UI must render as inert text rather than an anchor. Falling back to the raw host would make a failed check byte-identical to a passed one. - Leads with the registrable domain (eTLD+1), because punycode is irrelevant to `accounts.google.com.evil.tld` — the commonest shape and pure ASCII. `idna` is pinned explicitly in both the backend image and the test requirements: it is currently an unpinned transitive of httpx, and a dropped transitive is invisible to /verify-local because the source imports fine on the host (the #1033 class). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(credentials): bounded in-container probe for live .env key status The status engine for the ent#127 checklist: one fixed, base64-injected probe that reports which declared credential variables actually hold a value. "Set" is defined as agreement with the agent's OWN post-injection exporter (`agent_server/routers/credentials.py`), pinned by a parity test whose replica is anchored on the owning function via `ast` — not a `str.find` offset, which returns -1 on a rename and silently asserts against nothing. Two deliberate, documented departures sit outside that parse: bytes are decoded with `errors="replace"` (the exporter's strict `read_text()` raises and exports NOTHING, so one bad byte would report a fully-configured agent as empty), and emptiness is tested after `.strip()` (a whitespace-only value is a green row in front of an agent that will 401). The exec is bounded three ways, because none is sufficient alone. `execute_command_in_container` accepts a `timeout` and never references it again; `container_exec_run` has no timeout parameter, docker-py's `exec_run` has none, and its socket reader polls with no timeout before every `recv`. The call runs on a `ThreadPoolExecutor(max_workers=4)` shared by EVERY Docker operation in the backend, so four wedged calls stop the whole Docker layer — and it is agent-triggerable, since the agent owns `/home/developer/.env` and `mkfifo` on it blocks `open()` forever. So: container-side `timeout(1)` (the load-bearing one — self-termination closes the socket and actually reclaims the pool thread, which an asyncio cancel cannot), `asyncio.wait_for` to bound the request, and `stat.S_ISREG` before `open()` to close the FIFO vector at source. `compatibility/collector.py` has the identical hole; that is filed separately. Zero policy crosses the image boundary except the predicate itself, which is spliced in from real source so the tested code and the shipped code are the same code. No charset filter (it would be a hidden fifth member of credential_charset.py's MEMBERS list, and narrower than the runtime it audits), no YAML parse (alias expansion is a 443 B -> 52 MB amplifier). The probe emits key NAMES only, never a value, length or hash; `result["output"]` is never logged, because on failure it holds an exception string. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(credentials): assemble the per-agent requirements report Joins the ent#128 declaration against the live probe. Authority is the LIVE workspace, because a forked or hand-edited agent's requirements drift from the catalog entry it was created from — AC #3's case; the catalog is the fallback only, and it is reachable ONLY from an already-failed live read. Four decisions worth naming: - `degraded` DOMINATES `no_credentials_required`, unconditionally. A degraded lookup and a genuinely credential-free agent produce a textually identical empty requirement set, and "Ready — this agent needs no credentials" is the one state a user never investigates. An EMPTY catalog result counts as degraded, not as data: `get_github_template` returns `_build_template(repo, {})` — empty requirements, not None — when the fetch fails, and with no PAT GitHub's 60-req/hr anonymous limit makes that the *expected* outcome for the ent#123 tokenless fleet. - `.env` absent is a definite `missing`, never `unknown`. `_stage_config_files` guards on `template_data`, which only the `local:` arm populates, so a `github:` agent has no generated `.env` at all — and that fleet is AC #3's literal audience. `unknown` is reserved for "we could not look". - A fourth state, `declaration_incomplete`. AC #1 names three sources; using `credentials:` alone yields a confidently-wrong green, since 12 of 25 bundled templates declare `credentials: {}` and 13 declare nothing, so a legacy template with `${SLACK_BOT_TOKEN}` in `.mcp.json.template` would render as needing nothing. Those names are an anti-green signal only — advisory, never required, never blocking. `.mcp.json.template` does not become a declaration authority. - Tri-state `required` survives end to end and never counts toward `blocking`; platform-injected variables are excluded from the rows and counted separately, read through the PUBLIC `operator_supplied_credential_names` so this module keeps its zero-edit relationship with template_service. Hardening: YAML aliases are refused at compose time (this template.yaml comes from an agent-writable workspace, and alias expansion is a measured 443 B -> 52 MB amplifier here); the normalizer is wrapped at the CALL only, never around the build, because blanket swallowing is what turns a raise into a verdict indistinguishable from a pass; and the GitHub catalog arm goes through `asyncio.to_thread`, since `_get_cached_metadata` uses a synchronous httpx client whose 10s timeout would otherwise stall the whole worker. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(credentials): GET /api/agents/{name}/credential-requirements Owner-only AND human-only, deliberately stricter than the coarse `/credentials/status` beside it. `get_authorized_agent_by_name` resolves an agent-scoped MCP key to the owner user carrying the owner's role — only *connector* principals are fenced — so under the read gate an agent's own injected `TRINITY_MCP_API_KEY` would reach this for every sibling its owner can access, which on a default admin-owned install is the whole fleet including other users' agents. What it discloses is a targeting map, not a status light: it names `STRIPE_SECRET_KEY` per agent and says which are populated (worth stealing) and which are empty (whose operator is about to paste one). `/credentials/status` gets away with the read gate because it returns a COUNT and names nothing. Every sibling route that names or writes credentials is already owner + human-only, and a read gate must equal the write gate it drives: a shared user cannot submit anyway, so the looser gate would give them a checklist of dead inputs whose only working function is disclosing which of the owner's secrets are missing. Backpressure, because every uncached call spawns a container process against the backend's shared 4-slot Docker pool: a per-user rate limit at the router, plus a cross-worker single-flight lock and a short cache in the service (the router holds no logic, Invariant #1 — the same split `compatibility/fixes.py` uses). The cache is generation-checked through Redis and invalidated by both `.env` writers: it is per-worker while a POST lands on whichever worker served it, so purely local invalidation would leave the other worker reporting "missing" for a variable the operator just set. An audit row is written on the read — every sibling credential route logs one, and silence on the route that enumerates a credential inventory reads as an oversight. Counts only; a variable name never reaches the audit log. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(credentials): guided credential setup checklist UI The operator-facing half: what the agent needs, what is already set, and where to get each one — writing through the EXISTING owner-gated inject path, so there is one writer and no new backend write surface. Rendering contract, enforced by a source-anchored guard because this repo has no component-test runner (only Playwright e2e against a live stack): - Author text is interpolated as TEXT and deliberately NOT routed through `utils/markdown.js`. Markdown would be a widening, not a mitigation: it hands the template author an arbitrary `[label](url)` surface immediately beside a credential input, which is what having one validated `setup_url` exists to prevent. - The anchor text is always the parsed host, never `title` — a `<a href="https://evil.tld">OpenAI API keys</a>` recreates the userinfo attack in pure HTML with no validator in the way. An unverified host renders as inert text, and `https:` is re-checked at render rather than trusted. - The registrable domain is emphasised inside the full host, because `accounts.google.com.evil.tld` is the commonest shape and punycode says nothing about it. - `secret` masks on `!== false`, so an absent or malformed value still masks; `default` is a placeholder only, and only when the author marked the variable non-secret — prefilling it would turn author YAML (or a prompt-injected agent's own rewritten template.yaml) into a one-click credential write. The checklist renders for a STOPPED agent — the endpoint answers with a degraded body, and copying `loadCredentialStatus`'s running-guard onto it would have made the whole degraded design dead code. Only the inputs are gated. Two latent defects in the write path are fixed, because a per-row checklist promotes read-merge-write from a rare bulk paste to the normal interaction: - The merge base is now MANDATORY. `formatEnvContent` rewrites `.env` wholesale, so swallowing a transient read failure as "start fresh" wiped every credential already configured; only a genuine 404 is a safe empty base. - `parseEnvText` now unescapes what `formatEnvContent` escaped. The round trip was lossy in one direction only, so a value containing a quote grew one backslash PER SUBMIT — for every other credential in the file, not just the one being edited. Proven by an executed node round-trip, not a grep. The store action goes through `api.js` (Invariant #7 — it owns the auth interceptor and the 401 redirect); its raw-axios neighbours predate it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(credentials): requirements §3.6, architecture, and the ent#127 flow Tiered-docs classification is "new capability", so all three land: requirements §3.6 (appended — §3.5's existing "the per-credential checklist is ent#127" forward pointer is left untouched), three architecture edits (endpoint row, two service-catalog entries placed under Auth & Credentials rather than the Core block, and the CRED-002 note), and a new feature flow. A new flow doc rather than an extension of template-processing.md: that flow is catalog/template-time, this is per-agent runtime. One cross-link added there. The architecture note deliberately records three decisions so a later reader does not "fix" them: nothing is vendored and there is no agent-server mirror (so no Invariant #5 obligation attaches), the probe is deliberately separate from the #668 compatibility collector, and there is deliberately no MCP tool — recorded in architecture.md, not only the PR body, so /validate-architecture can see the Invariant #13 decision. Both index rows added — Recent Updates and the Authentication & Security category table. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(credentials): route-wiring smoke + test-runner catalog for ent#127 The unit suite mounts `routers/credentials.py` on a synthetic FastAPI app with the auth dependencies overridden, so it structurally cannot catch the #1069 escape class: whether the route resolves through the real `main.py` and whether the real `get_owned_agent_by_name` + `reject_agent_principal` chain runs. A path-param mismatch or a shadowing sibling would 404 every call with the unit suite still green. Three live-backend smoke tests close that, needing no agent — a nonexistent name is enough to prove the dependency ran, and the assertion is that the detail is NOT FastAPI's bare routing "Not Found". The uniform 404 those tests see is the point, not a limitation: `get_owned_agent_by_name` deliberately answers identically for "no such agent" and "not yours" (Invariant #8 self-uniformity), so the test cannot distinguish them either. Catalog updated: Credentials & Configuration entries, a dated Recent Test Additions block, and the unit-test statistics line. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(credentials): sync credential-injection.md with the live write path /sync-feature-flows over the ent#127 commits. `routers/credentials.py` and `CredentialsPanel.vue` both changed, which maps to credential-injection.md. Flow 1 cited `composables/useAgentCredentials.js:177-237` — a file that still exists and is re-exported from `composables/index.js` but that NO component imports. The live implementation is `CredentialsPanel.vue`, and the composable is a dead duplicate still carrying the pre-ent#127 versions of both defects. Documented as such so a future reader does not "restore" the live path from it. Flow 1 now shows the mandatory merge base and the quote round-trip, with the agent-side escaping mismatch recorded as a named residual rather than implied fixed. Added the cache-invalidation note on the inject/import writers and a Related Flows section pointing at guided-credential-setup.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(credentials): three failures that each render as a pass, and the docs gate Review follow-ups on ent#127. All three share one shape: a defence that fires correctly and then reports the result on the success path. 1. A caught normalizer raise read as "Ready — needs no credentials". `_safe_normalize` wrapped `normalize_credential_requirements` narrowly (the ent#128 `run_static` lesson) and returned `[], [error]` — but `build_report` then set `requirements_source="live_workspace"` with `degraded_reason=None`, so the empty list scored `no_credentials_required`: a green headline with the real reason folded into a collapsed `<details>`. The module's own comment forbids exactly this. `_safe_normalize` now returns an `ok` flag the caller converts into `degraded_reason="template_unreadable"` — reusing the existing enum rather than minting a fifth value, since every consumer already handles it and `errors[]` distinguishes the two causes. The covering test passed throughout: it asserted the exception was caught and stopped there. Catching is half the fix; propagating it into the state machine is the other half, and a test that stops at "it was swallowed" cannot tell them apart. It now asserts the resulting STATE. 2. A trailing DNS root dot moved the eTLD+1 emphasis off the attacker. `_registrable_domain` splits right-anchored, so `evil.tld.` adds an empty label and shifts every label one place: `accounts.google.com.evil.tld.` emphasised `tld.` and dimmed the true registrant. That inverts the module's PRIMARY defence — punycode canonicalisation is irrelevant to an all-ASCII subdomain attack; the bold IS the mitigation — and it costs one character the template author fully controls, next to a "paste your API key" input. Fixed with `rstrip(".")` plus a fail-closed empty check, and the adversarial table now carries the trailing-dot form of each case. 3. The single-flight lock released leases it no longer owned. `_LOCK_TTL_SECONDS` is reachable in normal operation, not pathologically: the probe is bounded at `_REQUEST_TIMEOUT` (20s) and the catalog fallback adds `get_github_template`'s own 10s HTTP timeout — exactly the TTL. Past it another worker may hold the key, and the bare `DELETE` in the `finally` freed it, letting a third caller probe the same container concurrently: the precise failure the lock exists to prevent, silently. Now a random per-acquisition token released by compare-and-delete via the shared `lock_token_matches` (#1919); a constant value makes the compare a tautology. Fail-open on Redis absent/erroring is unchanged — a Redis outage must degrade to "no backpressure", never to a 409. Frontend: that 409 is a concurrency signal, not a verdict, and a second viewer (another tab, operator, or uvicorn worker) inside the ~1s probe window gets it on a healthy agent. The checklist renders `v-if="error"` AHEAD of `v-else-if="report"`, so surfacing it blanked a report that had already loaded. Retried once behind a resettable latch (once per episode, not once per session), and a failed refresh no longer clobbers a report on screen. Also `:title` on the setup_url anchor: the visible text is deliberately host-only, so the full destination needs to be reachable — and it must be the URL, never the author's label, which would rebuild the deception one layer down where no validator looks. Docs: the flow doc was missing `## Testing` and `## Related Flows` (present in every sibling flow) — added, with the coverage table, the four named edge cases, and an explicit "not covered" note. Backpressure and eTLD+1 sections updated for the behaviour changes above. Two comments claiming a follow-up was "filed separately" now say plainly that it is not filed on either tracker: a comment asserting a ticket exists is the reason nobody re-checks. 208 unit tests pass. Refs trinity-enterprise#127 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: trinity-ability <309458136+trinity-ability@users.noreply.github.com>
vybe
pushed a commit
that referenced
this pull request
Aug 3, 2026
…ters, scale, export (#1534) (#1838) * feat(prompt): teach agents the report tool in the platform prompt (#1535) The report MCP tool (#918) has been fully functional and invisible: nothing in PLATFORM_INSTRUCTIONS mentioned it, so reports only got published by agents whose own CLAUDE.md happened to say so. Add a "Publishing Reports" block — the call, when to reach for it, the payload shape per display_hint, and the aggregate-before-publishing expectation given the 256 KB cap. The shape table is the load-bearing part. An agent cannot guess `tiles` vs `metrics` or `markdown` vs `body`, and guessing wrong fails SILENTLY: the write succeeds and the dashboard quietly falls back to the raw JSON viewer. So the documented shapes are lifted from the renderers rather than invented, and pinned by tests against both other surfaces — the MCP tool's display_hint enum and the ReportRenderer payload keys. Verified those guards bite (tiles->metrics, timeline->chronology => 2 failed). One explicit line separates reports from the operator queue, because the neighbouring block documents an `alert` request type that looks like the same thing; without it agents publish a report when they need an approval and nothing ever answers them. Runtime-awareness came free via _adapt_instructions_for_runtime (#1187), but two things did need doing: the Codex orientation note enumerates tool names, so `report` joins it, and the call example is indented independently of the call name — stripping the mcp__trinity__ prefix would otherwise misalign every continuation line. Budget: the block ships on every turn of every agent, so the first draft was trimmed 1.8 KB -> 1.3 KB (~330 tokens, +18% on a 7.1 KB prompt) and a test caps it at 2 KB, making future growth a decision rather than an accident. Additive: templates that already instruct reporting are unaffected. First child of epic #1534 — chosen first because the other four (export, scale, read-back, search) all process agent_reports rows, and would otherwise be designed against a mostly-empty table. Related to #1535 * feat(reports): let agents read back their own reports via MCP (#1538) Second child of epic #1534, on the same branch as #1535 because the two are one behaviour: the prompt block tells an agent to publish, and this tells it what it already published. Landing them apart would have shipped a prompt that says "continue the series" against a write-only surface. `list_reports` (metadata; filters agent_name/report_type/hours/search, paged) and `get_report` (payload by id) proxy the EXISTING access-controlled endpoints — no new endpoint, no new table, no new tenant-boundary logic in the MCP layer. The one gate the backend structurally cannot apply: an agent-scoped key resolves to its OWNER, so the backend scopes a read to everything the owner can see — wider than the calling agent's permits. The tools narrow a broad listing to {self} ∪ permitted (the #1104 rule list_operator_queue established) and re-check the owning agent on get_report. A denied get_report returns the backend's own "Report not found" shape rather than a distinguishable 403. That is deliberate: GET /api/reports/{id} answers 404 precisely so an id cannot be probed for existence, and returning "exists but forbidden" for agent keys would undo that choice one layer up. Write is untouched and stays self-gated — reading another agent's reports never widens what you can write. The #1535 prompt block now points at read-back in one sentence ("read back what you already filed... that is how you continue a series"), and the Codex orientation lists the two new tool names so the stripped prompt stays complete. Block is 1600 chars, still under the 2000 cap the #1535 test pins. Tests: src/mcp-server/src/tools/reports.test.ts — 9 cases driving the real execute() against a fake client: the pure filter, broad-listing narrowing for an agent key, no narrowing for a user key (with getPermittedAgents throwing to prove it is not consulted), scoped denial, self-listing, route selection, and the not-found-shape check on a denied get_report. Verified live against a running instance: list returns summaries with no payload field, get returns the payload. Docs: architecture.md (MCP catalog row 1→3 tools + read-back bullet), requirements FR-8, feature-flows/agent-reports.md, feature-flows.md changelog. Related to #1538 * fix(reports): constrain the hours window and fail closed on get_report (#1538) Two findings from /review on this PR, both mine: 1. `hours` accepted any non-negative integer, but the backend whitelists `_VALID_HOURS = {0,1,6,24,168,720}` and silently coerces anything else to 168 (routers/reports.py:136). So `hours: 48` answered with SEVEN DAYS of reports and nothing told the agent its window was ignored — the worst shape for a tool whose whole job is "read back what you filed, then decide". The schema now mirrors the whitelist and rejects the rest. 2. `get_report` re-checked the owning agent only `if (owner)`. A response without `agent_name` skipped the check and returned the payload — an auth gate failing OPEN on malformed input. Now an agent-scoped key with no resolvable owner gets the same "Report not found" as a denial; other scopes are unaffected (the backend already gated them). Also documents on the `hours` param that it is ignored on the per-agent route — `search` already said so and `hours` did not, which made the omission look deliberate for one and accidental for the other. #1539 gives that route both. Tests: 4 new cases — schema rejects 48 / accepts 24 and 0, the per-agent call carries only {report_type, limit, offset}, get_report refuses an ownerless response for an agent key and still serves a user key. 13 passed. Related to #1538 * feat(reports): search + time window on the per-agent list (#1539) Third child of epic #1534. The fleet list has had report_type/hours/search since #918; the per-agent route had report_type only. Two consequences: the Agent Detail Reports tab was a flat unfilterable list, and any caller scoping to one agent — including the #1538 list_reports tool shipped hours ago — had both filters silently dropped. Both routes now build their WHERE from the SAME _fleet_conditions, with one parameterized difference: `search` matches agent_name on the fleet list (that is how you find "everything scout published") but NOT on a single-agent list, where every row already carries that name — searching "recon" inside agent `recon-bot` would have returned its entire history, indistinguishable from search being ignored. `hours` is whitelist-validated on both routes (_VALID_HOURS), falling back to the 7-day default instead of erroring, so an old client keeps working. The MCP tool now passes both down the scoped path and the "ignored when agent_name is set" caveats are gone from its description. Frontend: the fleet view's filter bar, minus the agent picker, on the per-agent tab; type options derive from the loaded page (no per-agent stats endpoint exists and inventing one for a dropdown would be a new surface); search debounced 300ms; the empty state now distinguishes "no reports yet" from "no reports match these filters". PAYLOAD IS NOT SEARCHED, deliberately. A LIKE over a 256 KB TEXT column with no index degrades exactly as reporting succeeds; an FTS answer belongs with #1537's storage rework rather than behind a filter box that looks free. Found by verifying live rather than by test: database.py delegated to the ops layer POSITIONALLY, so two new parameters rebound limit→hours and every request 500'd with "unexpected keyword argument 'hours'". A wholesale-mocked db cannot see that (learnings 2026-07-04). The facade now forwards by keyword and test_facade_forwards_the_new_filters pins it — verified failing against the positional version. Tests: 5 backend cases (search matches title/type, does NOT match the agent's own name while the fleet list still does, window excludes older rows, filters compose, facade forwarding) + the MCP test inverted to the new contract. 16 passed across the report suites; tsc clean; 13 MCP tests pass. Verified live: seeded 3 reports on a real agent, exercised every filter through the API, and drove the UI — the search box narrows the list to 1 row. Related to #1539 * feat(reports): raise the payload ceiling and window tabular reads (#1537) Fourth child of epic #1534. Measured before designing: on a live fleet the existing reports averaged 201 bytes and the largest was 683 — four orders of magnitude under the 256 KiB cap. So the cap was never a limit agents were hitting; it was the wall the FIRST real tabular report would hit. That measurement decides the shape. Ceiling 256 KiB -> 5 MiB, and GET /api/reports/{id}/rows windows a `table` payload (offset/limit, columns once, the true total). Storage stays a single TEXT blob and there is NO migration: with no payload anywhere near the old cap, an off-row rows table would be a schema commitment made against a hypothetical. Frontend fetches tabular reports through the row reader — branching on the display_hint already present in the summary, so no extra request decides — with a "Showing N of M · Load more" footer. Other hints are bounded documents and still fetch whole. Create gains a Content-Length pre-check so an oversized body is refused on the header rather than after re-serializing the parsed payload; the exact byte check still enforces, and an unparseable header falls through to it rather than bypassing the cap. Non-tabular payloads answer 400 on the rows route (no row axis to slice, and inventing one is worse than saying so). No-access answers 404, matching GET /reports/{id} so the sibling route can't probe an id for existence. Verified end to end, not just unit-tested: a 12,000-row / 1.16 MB report (4.5x the old cap) creates successfully, the row reader answers total=12000 with 100 rows, and expanding the card in the UI transfers 8,699 bytes instead of 1.16 MB — 137x less over the wire. HONEST RESIDUALS, both documented rather than papered over: * the row slice happens in Python after the whole blob is read, so it bounds the RESPONSE, not the read. Moving it into SQL needs the off-row model. * Starlette buffers the body before the handler runs, so the Content-Length guard bounds storage and response size, not peak memory. A true streaming guard needs the body off the typed-model path (the webhooks.py pattern). Both should be triggered by a measured payload distribution approaching the new ceiling, not by this issue's premise. `request` is optional on create so direct in-process calls keep working — FastAPI injects it regardless — which is what kept the #918 endpoint tests green after the signature grew. Tests: 8 new cases (ceiling raised + still enforced, header rejection, unparseable header falls through, windowing with true total, offset window, past-the-end is an empty page not an error, non-tabular 400, missing/inaccessible 404). 25 passed across the report suites. Related to #1537 * feat(reports): export a report as .xlsx or .pdf (#1536) Fifth and last child of epic #1534. GET /api/reports/{id}/export?format=xlsx|pdf renders a stored report as a real spreadsheet — typed cells, both row encodings (positional list and column-keyed object) landing in the declared column order — or a formatted PDF where a table stays a table and markdown stays prose. Builders are pure (payload, display_hint, title) -> bytes in services/report_export.py; the router owns access, format validation, headers. Tests read the produced file BACK (openpyxl loads the workbook, the PDF magic is checked) because a mocked workbook asserts that a method was called, not that a value landed in the right cell — which is the only question an export raises. Shape mismatch degrades, never 500s: kpi -> label/value/unit, timeline -> event columns, anything unrecognized -> pretty-printed JSON in one cell. None of those is an error path; a stakeholder holding a plain file beats one holding a trace. Three decisions worth reviewing: * Dependencies are openpyxl + reportlab, both PURE-PYTHON wheels, so the image build is unchanged beyond two pins. WeasyPrint was rejected for needing cairo/pango — a PDF button should not become a container-build concern. * They are imported LAZILY. start.sh does not rebuild on an in-place upgrade (#1814), so a module-level import would take the whole reports router down on such an instance. Lazily importing turns that into one endpoint answering 503 with a rebuild hint. Confirmed live on an un-rebuilt container: 503 with "Rebuild the backend image", not a 500. * Access reuses the detail route's 404-not-403 — an export URL must not become the existence oracle that route deliberately refuses to be. Content-Disposition is built from a sanitized title (quotes/newlines/separators stripped, not escaped) and carries nosniff like the FILES-001 download. The PDF caps at 2000 rows WITH A VISIBLE NOTE pointing at the spreadsheet. Silent truncation of an export is a data-integrity trap, and a 12,000-row PDF is not a document anyone reads. The cap decision is a separate pure function because reportlab compresses content streams — asserting on rendered bytes would have proven nothing about whether the user was told. Agent-authored text is escaped before reportlab parses its mini-HTML dialect: an unclosed <tag in a report body would otherwise reflow or crash the document. Verified live against real data, not only unit tests: the 12,000-row / 1.16 MB report from #1537 exports to a 362 KB .xlsx that reads back with all 12,000 rows and typed values, and to a 153 KB PDF; a markdown report exports to a 1.7 KB PDF. Tests: 17 new cases. 48 passed across every report suite. Related to #1536 * fix(reports): one gated fetch helper + interpolate the real payload ceiling (#1838 review) Addresses the two blockers from @AndriiPasternak31's /validate-pr. 1) CI red on a real regression, not a flake. `test_1310_auth_wiring::test_no_inline_auth_gates_in_routers` flagged two new inline agent-gates (`export_report`, `get_report_rows`). The design was right — uniform 404 so an export URL can't become the id oracle the detail route refuses to be — but the wiring was three byte-identical copies of the gate, which is exactly the duplication the guard proxies for. Extracted `_report_or_404(report_id, current_user)`; detail / rows / export all call it. The allowlist drops from a would-be three entries to one, and the gate exists once, so it cannot drift into a 403 in one route only. The allowlist meta-test moves with it (its synthetic function has to carry the allowlisted name or it stops testing anything). Green across seeds 1/2/3. 2) The prompt shipped a ceiling 20x below the enforced one. The block said `max 256 KB` while `REPORT_PAYLOAD_MAX_BYTES` was already 5 MiB in the same PR. That partly cancels #1537: an agent told the wall is at 256 KB pre-aggregates exactly the payloads the raise exists to accept. The block now INTERPOLATES the constant (`__REPORT_PAYLOAD_MAX__` substituted at import) rather than restating it, so it cannot drift again, plus the seventh drift guard asserting the figure matches and that `256 KB` is gone. A third agent-facing surface carried the same stale number and was not in the review: the MCP `report` tool's own `payload` description. Fixed — a tool description is read by the model just as the prompt is. Docs swept where they state CURRENT behaviour (architecture.md x3, agent-reports.md x3, lifecycle-observability.md x2). The before/after table, the 201-byte measurement narrative, and `models.py`'s "raised from 256 KiB" are history and stay as written. Not changed: `request: Request = None` (review item 5). `Optional[Request]` is wrong here — FastAPI special-cases the bare annotation as an ASGI injection; wrapping it makes FastAPI build a Pydantic field for it and the module fails to import outright ("Invalid args for response field!"). Verified by making the change and watching 9 tests fail. Comment added so it isn't re-suggested. Related to #1535, #1536, #1537, #1538, #1539 * fix(reports): export download must carry the Bearer token (#1536) Reported from the UI: clicking **Export .xlsx** returned 401. The buttons were `<a :href="/api/reports/{id}/export?format=xlsx" download>`, carrying a comment that claimed "the session cookie/JWT interceptor is not involved in a binary body". That reasoning is wrong for this platform. Trinity holds its JWT in localStorage and attaches it via the `api.js` request interceptor; a raw browser navigation sends no Authorization header at all, so the endpoint correctly refused it. Export was unusable from the UI — the entire point of #1536 — even though the endpoint itself was fine. Why it survived verification: I tested the ENDPOINT with `curl -H "Authorization: Bearer ..."`, which passes, instead of the BUTTON, which is the feature. An endpoint that works under curl and 401s under a click is exactly the gap that shape of testing cannot see. Fix: `downloadReportExport(reportId, format)` in the reports store fetches through the shared api client (so the interceptor runs), then hands the browser a blob URL. Mirrors the existing `agents.js:getFilePreviewBlob` pattern and keeps Invariant #7 (one API client, no raw fetch). The server's Content-Disposition filename is still what names the file. Both panels (per-agent + fleet) switch from anchors to buttons with an in-flight state, and a failure now surfaces the backend detail — notably the #1814 503 rebuild hint — instead of a silent no-op. Guard: `test_1536_export_download_auth.py` — a static check, because the defect lives in markup rather than in a callable. Asserts the anchors are gone, both panels call the store helper, and the helper requests a blob through `api`. Verified it fails on the pre-fix markup (5 failed) and passes after. Verified in a real browser, clicking the real buttons: both formats download 200 with the server-supplied filenames (362 KB xlsx / 153 KB pdf), no alert. Related to #1536 * fix(prompt): stop the file-sharing block stealing rows-and-columns results (#1535) Found in live use, not in a test. Asked "I need a list of weather for 500 places in europe" from the Chat tab, the agent hand-wrote a CSV + JSON to /home/developer/public/, called share_file, hit FEATURE_DISABLED because sharing was off for that agent, and delivered nothing downloadable. The report block sat directly underneath, unused — on the single request it describes best (500 rows, tabular, exportable). Two causes, both in the prompt, both mine: 1. The "Sharing Files with Users" block sits IMMEDIATELY above and its trigger list literally read "(CSV, PDF, report, image, exported data, etc.)". It claimed the word `report` and `exported data`, so it won every structured request. The dead-end message the user saw is that block's own fallback text verbatim. 2. The report block's trigger was framed entirely around recurring work — "a scheduled run", "numbers someone compares against next period". A one-off interactive "give me 500 rows" matched none of it. Fixes: the file-sharing trigger now lists only genuine file artifacts (image, PDF, document, generated asset) and explicitly hands structured results to the report block, noting reports export to Excel/PDF anyway and work even when file sharing is off. The report trigger now leads on SHAPE and VOLUME — "any result that is rows-and-columns … a table you just produced (10 rows or 10,000)" — and says outright that publishing a report beats hand-writing a CSV + share_file. The lesson generalises: a prompt block cannot be validated in isolation. Six existing guards pinned this block against the MCP enum, the renderer keys and the byte ceiling, and every one passed while the block was being out-competed by its neighbour. What the ADJACENT block claims decides which one fires. Guards: `test_file_sharing_block_does_not_claim_structured_results` (scoped to the parenthesised claim list, so the handoff pointer doesn't self-trip) and `test_report_trigger_covers_a_one_off_table_not_only_scheduled_work`. Verified by re-running the exact failing request on the same agent: type : weather.europe_500_cities title: Current Weather — 500 European Cities (2026-07-29) hint : table rows : 505 cols : City, Country, Temp °C, Feels Like °C, Humidity %, Wind km/h, Wind Dir, Precip mm, Condition Honest caveat: the agent still called share_file once before publishing, so the redirect is a strong preference rather than an absolute. The deliverable is now the report, which is the outcome that matters. Block is 1888 chars, under the 2000 budget. Related to #1535
vybe
pushed a commit
that referenced
this pull request
Aug 6, 2026
…nterprise#126) (#1911) * fix(manifests): drop acme-consulting.yaml — broken duplicate of the live seed The upcoming UI manifest picker (trinity-enterprise#126) renders one card per file in config/manifests/, which promotes these files from zero-consumer samples to the primary one-click install path on a fresh install. acme-consulting.yaml does not survive that promotion: * `cpu: 1.0` is an unquoted YAML float. `normalize_cpu` compares against VALID_CPU = ("1","2","4","8","16") as strings, and the scout/sage/scribe templates declare no `resources` of their own, so the manifest value survives the template merge and every one of the 3 agents fails at create — deploy returns status "failed" / HTTP 500. The dry-run does NOT catch it: preflight builds a throwaway AgentConfig carrying only name+template. * `auto_start:` and `trinity_prompt:` are not parsed by parse_manifest and were silently dropped. `trinity_prompt` is a typo for `prompt:`, so the prompt this manifest's author intended was never installed. * It declares the SAME system name (`acme`) and the same three short names as default-system.yaml, the first-run seed. The seed runs on every fresh install, so clicking this card would resolve to acme-scout_2/acme-sage_2/acme-scribe_2 — a duplicate fleet whose recovery is manual and per-agent. The cpu bug was masking this; fixing cpu alone would have unmasked it. Deleted rather than renamed. Renaming the system to `acme-consulting` unmasks a worse problem: the shared-folder mount is /home/developer/shared-in/{full_agent_name} (db/shared_folders.py:231) and the consumed templates hard-code the sibling names (scribe/CLAUDE.md:21-22, sage/CLAUDE.md:21,70, sage/.claude/commands/request-research.md:9), so the renamed fleet would document shared-in/acme-scout/ while mounting shared-in/acme-consulting-scout/ — exactly what default-system.yaml:16-17 warns about. Making the templates name-agnostic instead would change the agent-facing behaviour surface of the live default fleet from inside a UI issue. default-system.yaml already IS this fleet, correctly specified, and remains untouched. The structural fix for the defect class — validating merged resources in the dry-run preflight — lands with the rest of ent#126. Refs trinity-enterprise#126 * test(systems): characterize configure_permissions + create_schedules pre-refactor ent#126 extracts the permission-topology and schedule-iteration logic out of these two shipped writers into pure resolvers, so the dry-run preview and the real deploy compute the same thing from the same code. That refactor needs behaviour-preservation evidence, and neither obvious candidate supplies it: * A parity test asserting `resolve_permission_edges()` matches a writer that was just refactored to loop that same resolver is tautological — resolver and writer drift together and the test stays green. * test_ent125_resilient_system_deploy.py monkeypatches BOTH configure_permissions and create_schedules (lines 71-88), so that suite never executes either function and proves nothing about them. These 25 tests pin the writers' observable behaviour — the exact ordered db.set_agent_permissions / db.create_schedule call sequences and the integers returned — hand-derived from the shipped code and written independently of the resolvers. Captured GREEN here, BEFORE any production change; that run is the artifact the refactor is measured against. They pin, specifically, the truthiness guards that are easiest to "tidy" into a behaviour change: * full-mesh `if targets:` — a lone agent is skipped entirely, never written as [] * orchestrator-workers `if workers:` guards the WHOLE body, so a lone orchestrator with zero workers writes nothing and counts 0 * orchestrator-workers counts len(workers) by assignment, not by summation, and the worker-clearing calls are not counted * `explicit: {}` is falsy => the branch is skipped and nothing is cleared, which is NOT the same as the `none` preset * explicit phase 1 clears every agent that is not an explicit SOURCE, so a target-only agent is cleared first and granted-to second * explicit targets are filtered by membership (unknown targets silently dropped); an unknown source is skipped with a log warning * create_schedules maps manifest `cron` -> `cron_expression` and defaults enabled=True / timezone=UTC / description=None * count-only-on-success: a falsy db.create_schedule return is logged, not counted * create_schedules has no internal try/except — the raise escapes to deploy_manifest step 9 and prior writes stand (no schedule rollback) The odd permission branches are reachable in production despite validate_manifest rejecting unknown explicit sources/targets from a manifest: the partial-deploy path calls configure_permissions with `created_map`, a SUBSET of resolved names. Sync `asyncio.run` idiom rather than a bare `async def test_*`: tests/unit/pytest.ini is the effective inifile for this directory, so pyproject's asyncio_mode="auto" does not apply here and bare async tests are not collected. Refs trinity-enterprise#126 * refactor(systems): extract pure permission + schedule resolvers Preparation for the ent#126 dry-run preview, which must show permission topology and schedules. Those cannot be derived client-side: only the backend knows the resolved `_N`-suffixed agent names (resolve_agent_names already runs on the dry-run path), and a preview that re-implemented the preset rules would drift from the writer the first time either side changed. So the decision moves into pure functions that BOTH the preview and the writers consume: * `resolve_permission_edges(agent_names, permissions) -> (write_set, count)` returns the exact ordered sequence of db.set_agent_permissions calls the deploy would make, plus the integer it would report. An ordered list of pairs rather than a dict, so the write SEQUENCE stays faithful — clearing an agent and then granting to it is observable ordering a dict would normalise away. * `resolve_schedule_previews(agent_names, agents_config) -> [SystemSchedulePreview]` * `_build_schedule_create(schedule_data)` — the one manifest->ScheduleCreate mapping, shared by the resolver and the writer, so the key rename (`cron` -> `cron_expression`) and the three `.get()` defaults cannot diverge between preview and deploy. configure_permissions and create_schedules become thin loops over these. Behaviour is unchanged, and this is measured rather than asserted: the 25 characterization tests from the previous commit were captured GREEN against the pre-refactor writers and are still green (80 passed with the ent124/ent125/1759 system suites). Every truthiness guard the resolver preserves is documented in its docstring and pinned by those tests — full-mesh's `if targets`, orchestrator-workers' whole-body `if workers` and assignment-not-summation count, `explicit: {}` being falsy, explicit phase 1 clearing target-only agents, and the silent filtering of unknown targets. Logging: the per-branch messages are unified into one line per write plus a mode/count summary. Net MORE coverage than before (the `none` preset previously logged only a summary, never per agent) while keeping the mode context. No test asserts on these strings. models.py (Invariant #14): adds SystemSchedulePreview, BundledManifestSummary and BundledManifestDetail for the catalog, MANIFEST_MAX_BYTES (256 KB) with a Field(max_length=...) cap on SystemDeployRequest.manifest — which was previously unbounded, and is a size cap only, NOT a YAML-bomb defence — and three additive SystemDeployResponse fields (permission_edges, schedules_preview, system_view_requested). system_view_requested disambiguates a system_view_created of None, which today means both "no view requested" and "view creation failed and was swallowed". Refs trinity-enterprise#126 * feat(systems): dry-run preview shows topology + schedules, and validates resources AC #2 of trinity-enterprise#126: the preview must show agents-to-create, permission topology AND schedules. Only the first existed. * `permission_edges` + `schedules_preview` on the dry-run branch, computed by the pure resolvers the real writers consume. `permission_edges` collapses the resolver's ordered write-set to {source: targets} for display — lossless for the set of writes, since each branch writes any agent at most once. The topology is OPTIMISTIC (resolved against the full agent map, while a partial deploy configures against the created subset); the UI says so. * `permissions_configured` / `schedules_created` deliberately stay 0 on this branch. They mean "written", and repurposing a shipped field would mislead any existing consumer; callers count the new arrays. * A schedule that ScheduleCreate rejects now becomes a preview BLOCKER instead of a post-deploy warning discovered once the fleet already exists. The structural half of the manifest fix — the reason the deleted manifest's `cpu: 1.0` reached production at all is that `_preflight_template` validated template SHAPE only. It now also validates resources through the create path's own `normalize_cpu`/`normalize_memory`, with the create path's own precedence: `_resolve_local_template` overwrites config.resources when the template declares a block, so a manifest value only survives when the template is silent — exactly the case that failed. Verified end to end: a `cpu: 1.0` manifest previews as status "invalid" carrying "Invalid cpu '1.0': must be one of 1, 2, 4, 8, 16" where it previously previewed clean and then failed 100% of its agents. For a `github:` template the merge needs the network call this function refuses to make, so the DECLARED values are validated instead. That can over-report when the remote template overrides them — accepted deliberately and documented, because the alternative is silence about a value that is either fatal or dead config, and the fix is harmless either way. Two smaller honesty fixes: * `parse_manifest` records unrecognised top-level keys on the manifest and `validate_manifest` warns about them. Warned, never rejected — rejecting would 400 manifests that deploy today. This is the durable guard for the class that let `trinity_prompt:` (a typo for `prompt:`) and `auto_start:` sit in a shipped manifest doing nothing; both now surface. Recorded via a model field rather than a changed parse_manifest signature, which has two external callers. * A REQUESTED system view that fails to create now appends a warning. create_system_view swallows its exception and returns None, so the response was previously indistinguishable from "no view requested" and a caller would silently navigate to an unfiltered dashboard. Behaviour preservation: 84 tests green, including the 25 pre-refactor characterization tests and the untouched ent124/ent125/1759 suites. Refs trinity-enterprise#126 * feat(systems): read-only bundled-manifest catalog endpoints AC #1 of trinity-enterprise#126 asks for paste/upload AND/OR pick. Paste and upload need no backend at all, but "pick" had nothing to read: the only reference to config/manifests anywhere in src/ was the first-run seeder's single hard-coded filename. This adds the two read-only endpoints the picker needs. GET /api/systems/manifests -> [BundledManifestSummary] GET /api/systems/manifests/{id} -> BundledManifestDetail (+ raw YAML) Both require_role("creator"), mirroring POST /deploy rather than the looser get_current_user on the neighbouring list/get routes: a surface you cannot act on should not be advertised, and require_role also rejects connector principals. Invariant #4 is load-bearing TWICE here, which is why both routes are declared above the parameterized ones: * GET /manifests would be captured by GET /{system_name} and 404 as "system 'manifests' not found" — a silent, plausible-looking failure. * GET /manifests/manifest would ALSO be captured by GET /{system_name}/manifest with system_name="manifests". Verified by real requests: /manifests/manifest returns the detail route's JSON 404, not get_system_manifest's PlainTextResponse. `valid` means all THREE stages passed — parse, validate, and the same side-effect-free template/resource preflight the dry-run uses. parse_manifest alone is not a validity check: it accepts invalid names, unsupported template prefixes and bogus presets, and raises AttributeError (not ValueError) on a non-mapping `agents:`. If this is ever reduced to parsing, the field must be renamed `parseable`. Listing is fail-soft per file — an unreadable/oversized/invalid manifest is listed with valid:false and a reason instead of 500-ing the request, because one bad file hiding the other two is exactly how a broken bundled manifest stays invisible. Path confinement on {manifest_id} is layered, because no single check suffices: 1. character allowlist (also kills percent-encoded traversal, which FastAPI has already decoded by the time we see it, and ASCII-only kills homoglyphs) 2. EXPLICIT rejection of "", ".", ".." and any id containing ".." — the regex does NOT do this, since `.` is inside its character class and `..` matches it happily. Relying on the regex here is the #1759 lesson. 3. a length cap, so an over-long id is a 400 and not an escaping OSError(ENAMETOOLONG) surfacing as a bare 500 (found by probing) 4. the suffix is ours by construction (the id is a stem), so a caller cannot steer the extension at all; an explicit .yaml/.yml is tolerated and stripped 5. resolve() both sides then is_relative_to, which is what actually defeats a symlink inside the directory pointing outside it Reads open ONCE and fstat that same descriptor with O_NOFOLLOW, reading at most cap+1 bytes — config/manifests is a host bind mount in both compose files, so a stat-then-read sequence has a real swap/growth window and the file checked must be the file read. Symlinked entries are declined explicitly rather than surfacing as an unexplained ELOOP. Probed against 13 traversal shapes. MANIFESTS_DIR is env-overridable (TRINITY_MANIFESTS_DIR) and read at call time: the bare relative default is right at runtime (WORKDIR /app + the :ro mount) but CWD-dependent under pytest, and a catalog silently returning [] because the CWD differs is a silent failure. Not exported over MCP (Invariant #13) — this is a UI affordance and deploy_system already exists there. Also corrects the deploy docstring, which listed four statuses and omitted "invalid" (added by #1841), and now records that `status` covers agent creation only. Refs trinity-enterprise#126 * test(systems): bundled-manifest smoke, catalog endpoints, dry-run preview 89 tests across three files, completing the ent#126 backend coverage. test_ent126_bundled_manifests.py (15) — the test whose ABSENCE let `cpu: 1.0` ship. Table-driven over the real config/manifests directory (located from the test file, not the CWD), so a manifest added later is covered automatically. Asserts each file parses, validates, dry-runs `valid` through the real service, has no unrecognised top-level keys, and — the actual regression — that every agent's MERGED resources pass the create path's own normalize_cpu/normalize_memory with the create path's own template-wins precedence. Plus a cross-file check that no two manifests declare the same system name, and a pin on default-system.yaml staying schedule-free and prompt-free (its header states both; the picker now makes it clickable, so they are enforced rather than merely documented). Verified this suite actually catches the defects rather than merely passing: restoring the deleted acme-consulting.yaml turns 4 of these red — unknown keys, merged-resource validators, dry-run status, and the duplicate system name. test_ent126_manifest_catalog.py (47) — the endpoints, against a tmp_path catalog via TRINITY_MANIFESTS_DIR so nothing depends on the repo's own directory or the CWD. Covers summary fields, sets_prompt, already_deployed (incl. degrading on a DB error), fail-soft listing (unparseable / invalid / oversized / bad-resources files listed as valid:false, and one bad file not hiding the good ones), .yml and mixed-case suffixes, non-YAML ignored, symlinks declined, read-one incl. the tolerated explicit extension and the "invalid manifests must still open in the editor" case, and the creator/connector authorization matrix. Both Invariant #4 collisions are asserted through REAL requests and distinguished by response SHAPE, not just status: /manifests must return a list (not get_system's dict) and /manifests/manifest must return the detail route's JSON 404 (not get_system_manifest's PlainTextResponse). A third test proves the sibling export route still works for a genuinely-named system, so the guard did not break what it shadows. The traversal tests are split deliberately after probing showed two DIFFERENT layers stop these: dot segments like ".." and "." are collapsed by URL normalisation before routing (".." lands on GET /api/systems/, "." on the catalog listing — neither a traversal), while anything surviving normalisation hits the guard. So one test asserts 400 for the shapes that actually reach the handler (incl. "..yaml", where extension-stripping would otherwise hand a bare ".." onward, and "%2e%2e", proving the decode happens upstream of the regex), and a second asserts the property that actually matters across all 12 shapes: no response ever carries manifest content. Asserting 400 uniformly would have been asserting the wrong thing. test_ent126_dry_run_preview.py (27) — permission_edges across all three presets, explicit, `explicit: {}` and no-permissions, asserted at the RESPONSE level so the preview cannot silently disagree with the writer it describes; schedules_preview incl. enabled-defaults-True; the shipped counters staying 0 on a dry run; bad cpu/memory as `invalid` while template-supplied resources correctly override a bad manifest value (no false blocker); #1841's unresolvable-local-template blocker still firing; and the three error shapes the frontend normalizer must handle (400 string detail, 422 list detail). Full unit suite: 5779 passed, 1 failed — test_agent_analytics.py TestTimelineGapFill::test_day_stacks_present_in_by_type, which reproduces identically on pristine origin/dev in a throwaway worktree (a UTC-vs-local date boundary; it was run near midnight local). Pre-existing, unrelated to this branch. Refs trinity-enterprise#126 * feat(ui): install a system from a manifest — paste/upload/pick, preview, deploy The UI half of trinity-enterprise#126. `POST /api/systems/deploy` has existed for a while but was reachable only by curl or MCP. Home is a `?tab=`-driven catalog on the existing Templates page (`?tab=agents` | `?tab=systems`) rather than a 7th NavBar entry — the bar already has 6, and "install an agent template" and "install a system" belong in one hub. ent#15's agent-import wizard and ent#108's registry slot in as further tabs, which is the reconciliation the issue asks for. The existing Agents content moves under its tab unchanged, including its raw-axios fetch (Rule #2 — that Invariant #7 drift is real but not this PR's business). stores/systems.js is a new domain store (Invariant #6), deliberately not bolted onto systemViews.js: a "System" is a manifest-deployed set of agents sharing a name prefix, a "System View" is a saved tag filter. Different domains that share a word. Goes through the single `api` instance (Invariant #7). Two things drove the design, both of them traps in the backend contract: 1. `normalizeError` collapses SIX outcomes into one renderable shape, switching on `status` and never on the HTTP code. `partial` and `invalid` arrive as HTTP 200 (a naive .then() renders a degraded outcome as clean success), and `failed` arrives as HTTP 500 WITH THE FULL REPORT AS THE BODY — so a naive catch throws away exactly the `failed[]` list AC #3 has to render. It is returned as a result, not an error. The remaining shapes are a 400 string detail (the commonest outcome for a paste UI), a 422 LIST detail, a bare 5xx, and no response at all. 2. `preview` is bound to `previewedText`, the exact string it was produced from, and Deploy is gated on them matching. Without that a user previews manifest A, edits to B, and deploys B while reading A's preview. Any source change — keystroke, file, or bundled card — invalidates it. Honesty rules the components follow, because each corresponds to a real way the backend can mislead: * Deploy result is headed "agents created", never "success". `status` describes agent creation ONLY — folder, permission, schedule, tag and start failures all land in `warnings[]` while `status` stays "deployed", so a fleet where every schedule failed and nothing started still reports "deployed". Warnings therefore get their own prominent panel, not a footnote. * The preview never says a manifest "will deploy": `github:` templates are not probed, and the topology is resolved against ALL agents while a partial deploy wires up only those created. Both are stated in the UI. * Deploying a manifest that sets `prompt:` or carries enabled schedules is gated behind an explicit acknowledgement checkbox, not a banner. It replaces the platform-wide prompt for every agent on the instance and/or starts recurring autonomous executions that spend budget — a banner is not consent for that. * An `_N`-duplicate warning is a confirm-grade panel, since on a fresh install re-installing a bundled manifest hits it by default and recovery is manual. * A timeout or a bare 5xx renders "outcome unknown — may still be running" and deliberately does NOT offer retry: cancelling the request does not cancel the server, and re-deploying duplicates every agent that succeeded. It offers the agent list instead. AC #5 (no dead empty state): deploy always tags every created agent with the system name, so `/?tags=<system>` is a fallback that always works, with `/?view=<id>` preferred when the manifest declared a system_view and it was created. Dashboard.vue gains a small additive reader for both, mirroring its existing `?onboarding=1` handling and yielding to an active system view. Plain textarea, not the orphaned monaco YamlEditor.vue: monaco is a declared dep but unreachable (that component has had zero consumers since the Process Engine was decommissioned), and prod CSP is `script-src 'self'` with no unsafe-eval and no worker-src, while the dev CSP allows unsafe-eval — so `npm run dev` cannot prove prod. Every AC is satisfiable without it. Deferred with that reason recorded so it is not re-litigated blind. Systems tab gated on `hasMinRole('creator')` mirroring the endpoint (AC #6), with an explanatory empty state rather than a blank panel for lower roles. Note hasMinRole is a plain function — the composable's own docstring says `hasMinRole.value(...)` and is stale. All manifest-derived text (descriptions, failure reasons, warnings) renders as plain text, never v-html (H-005): `reason` is credential-sanitized server-side but NOT HTML-sanitized. Verified with a real production build (`npm run build`) since /verify-local is blind to src/frontend — clean, and all three components plus the store are present in the Templates chunk rather than silently tree-shaken. Refs trinity-enterprise#126 * test(e2e): system-install surface — tab, preview, and both transport traps Playwright coverage for the ent#126 install surface, driving the real stack: ?tab= deep-linking and reload survival, a bundled card loading into the editor, the preview's agents/topology/schedules tables, the acknowledgement gate for a manifest with enabled schedules, edit-after-preview disabling Deploy, and named error messages for both a 400 validation failure and malformed YAML (AC #4 — asserting the absence of "[object Object]" and "Traceback", the two shapes a naive normalizer produces). Both backend transport traps are exercised, since each is a distinct way a store can be wrong: * `status: "invalid"` arrives at HTTP 200, so a store switching on the HTTP code would render a blocked manifest as a clean, deployable preview; * `status: "failed"` arrives at HTTP 500 with the full report AS THE BODY, so a naive catch discards exactly the failed[] list AC #3 has to render. No test deploys for real by default. A deploy creates containers and there is no un-deploy — re-running a manifest creates `_N`-suffixed duplicates rather than converging — so an automated deploy would litter whatever stack it runs on. The one deploy assertion is behind SYSTEM_INSTALL_DEPLOY=1 and uses a manifest that cannot create anything (an unresolvable local: id), so even opted in it has no side effects. Advisory, not a required gate: frontend-e2e auto-runs on any PR touching src/frontend/** since #1526, and its known failure class is modal/overlay flake on a fresh zero-agent stack. Refs trinity-enterprise#126 * docs(systems): record the UI manifest install surface (ent#126) Rule #1 deltas for trinity-enterprise#126. feature-flows/system-manifest.md is the highest-signal edit: it told every reader "### UI — Status: Not yet implemented" and "## Frontend Layer — No UI implementation yet (API-only feature)", listing SystemManifestEditor.vue / SystemsList.vue / SystemDetail.vue as planned. Rewritten to what actually ships (three components + the store + the Templates tab host), with the six-shape error contract as a table, the honesty constraints and why each exists, and an explicit "still not built" section — SystemDetail.vue and a deployed-systems browser stay unbuilt and unowned, and the two retired component names are retired rather than left standing as a promise the doc keeps making. roadmap.md: new §16.5.2, and two corrections to §16.5 itself — it called `status` "tri-state ... plus valid", omitting `invalid` (added by #1841), and never recorded that `status` covers AGENT CREATION only, so a consumer rendering `status` without `warnings` can report a fleet as deployed when every schedule failed and nothing started. The trailing "prerequisite for ... UI manifest install" pointer now resolves to §16.5.2. architecture.md: the router and service catalog lines (kept to the ≤2-line catalog rule, detail in the flow doc), a Frontend note for the Templates tabs + stores/systems.js, and a System Manifests endpoint table carrying the Invariant #4 warning for BOTH collisions plus the `/api/systems/manifests` vs `/api/systems/{name}/manifest` naming adjacency. feature-flows.md: a Recent Updates index row, added by hand — /sync-feature-flows reliably forgets it. user-docs: an "Installing a system from the UI" section written for an operator, not a reader of the source. It leads with what the confirmation gates mean (a top-level `prompt:` replaces the global prompt for every agent; a schedule is enabled unless you say otherwise, so the fleet starts spending budget on a timer), how to read each of the five outcomes, why "agents created" is not "success", and why an "outcome unknown" must be checked rather than retried. Also states the two deliberate preview limits: `github:` templates are unverified until deploy, and agent names are provisional. Refs trinity-enterprise#126 * test(systems): guard the manifest-catalog route order in the assembled app /update-tests coverage review found the one gap the ent#126 suite structurally could not cover. `test_ent126_manifest_catalog.py` mounts `routers/systems.py` alone on a bare FastAPI app, which proves ordering WITHIN the router but says nothing about the real application, where 60+ routers are mounted and the handler is decided by the first FULL match across all of them. This is the #1069 class the test-runner catalog already calls out for the brain-orb routes. So this imports the assembled `main.app` and asserts the first FULL match for both Invariant #4 collisions — `/api/systems/manifests` vs `GET /{system_name}`, and `/api/systems/manifests/manifest` vs `GET /{system_name}/manifest` — plus that all three shadowed siblings (`get_system`, `get_system_manifest`, `deploy_system`) still resolve, so `manifests` is a genuine static-before-param precedence rather than a total shadow. OpenAPI is an order-independent path set and is therefore blind to this, so a schema check cannot catch it; a match-order assertion is the only guard. Mirrors `test_1483_route_order.py`, including its self-sufficient env setup and its loud-skip guard for the whole-directory sweep (an earlier module binds sys.modules['utils'] to tests/utils, so `import main` fails there — the assertions run standalone). Confirmed to fail on the bug rather than merely passing: moving the two routes below `/{system_name}` turns both collision tests red with "resolved to get_system" and "resolved to get_system_manifest" respectively. Note: /update-tests also updates `.claude/agents/test-runner.md`, which lives in the private trinity-dev submodule — that edit is deliberately NOT committed here, since bumping the gitlink would point this public branch at an unpushed private commit. Refs trinity-enterprise#126 * docs(tags): record the ?tags=/?view= deep link as a Dashboard filter entry point /sync-feature-flows gap: `views/Dashboard.vue` changed on this branch, and `agent-tags.md` — not `system-manifest.md` — is the doc that owns the Dashboard quick-tags / activeFilterTags mechanism in detail. It described two entry points into that state (a quick-tag click and a System View selection); ent#126 added a third (`applyDeepLinkFilters()` in onMounted), and a reader of that doc had no way to know a URL can seed the filter. Documents the precedence (`?view=` wins because a view carries its own filter tags; `?tags=` yields entirely when a view is already active) and why it exists — manifest deploy always tags every created agent with the system name, so `/?tags=<system>` is the always-works fallback that keeps a fresh install off an unfiltered dashboard. Refs trinity-enterprise#126 * fix(ui): the deploy acknowledgement must not survive a manifest edit Self-review of the ent#126 diff found the consent gate had the exact hole the preview/previewedText binding was built to close, one field over. `acknowledged` was reset when picking a bundled card, choosing a file, or starting over — but NOT when the user typed in the textarea. So: preview a manifest with enabled schedules, tick "I understand and want to continue", edit the YAML, preview again → the box is still ticked and Deploy re-enables. The user consented to manifest A's consequences (replacing the platform-wide prompt, starting recurring autonomous executions) and deployed manifest B's. Consent is per-manifest, so it now dies with the text it was given for: a watcher on `store.manifestText` clears it. That covers every source, because typing, file upload and bundled-card selection all funnel through `setManifestText`. Pinned by a new e2e case that ticks the box, edits to a different manifest with the same hazard shape, re-previews, and asserts the box is clear and Deploy is blocked. Also drops `duplicateWarnings` from the store: it was exported but never consumed (ManifestPreview.vue computes its own split, since it needs both halves — the duplicates get a confirm-grade panel and everything else a notes list), so it was a second copy of the same heuristic with nothing to keep it in step. Rebuilt clean. Refs trinity-enterprise#126 * fix(systems): stop the schedule preview claiming it validates cron Self-review caught a code comment that promised more than the code delivers. The plan this was built from called it a "cheap completeness win — build a throwaway ScheduleCreate inside the resolver purely to borrow its validation, so a bad cron surfaces in the preview instead of degrading to a post-deploy warning", and that premise is simply false: `ScheduleCreate` declares `cron_expression: str` with **zero** validators (verified — the model has no field_validator/model_validator at all), and `validate_manifest` only checks that the `cron` KEY is present. Nothing parses the expression. A comment that overstates a guard is worse than no comment: the next reader trusts it and stops looking. So the docstring now states what the mechanism actually catches (a schedule entry whose field TYPES the model rejects — reachable, because validate_manifest's checks are presence-only, so a non-string `name` passes validation and then fails construction) and states the gap explicitly: a syntactically invalid cron previews clean, deploys clean, and surfaces only when the scheduler tries to arm it. Left as a documented gap rather than silently half-fixed, because validating cron here would change a shipped path — manifests with a bad cron deploy today. Both halves are now pinned, including the gap: `test_a_syntactically_invalid_cron_is_NOT_caught` asserts the current contract, so if cron validation is ever added that test is the one that must change, deliberately. Also adds coverage for the model-rejects-a-schedule branch, which was written but untested. Refs trinity-enterprise#126 * fix(config): wire TRINITY_MANIFESTS_DIR into both compose files + .env.example /validate-pr's Config Packaging gate (the #1056 / trinity-enterprise#31 class): a backend `os.getenv()` that is not in `backend.environment:` of BOTH compose files is an inert lever on deploy. `TRINITY_MANIFESTS_DIR` was in 0 of the 3 files while its sibling `TRINITY_DEFAULT_SYSTEM_MANIFEST` — same feature area, same shape — is in all 3. Prod compose launches standalone with no base-compose merge and no `env_file:`, so dev-only wiring would not have carried over anyway. Worth noting the gate's own grep did NOT catch this: it matches literal `os.getenv("X")`, and this call site reads `os.getenv(MANIFESTS_DIR_ENV)` through a module constant. Found by diffing against the sibling variable instead. Verified by RENDERING the container environment (`docker compose config`) rather than grepping the source — both files now emit `TRINITY_MANIFESTS_DIR` into the backend env. That render also surfaced a real consequence: with `${TRINITY_MANIFESTS_DIR:-}` every deployment now sets the var to the EMPTY STRING unless an operator overrides it. `_manifests_dir()` uses `os.getenv(...) or default` so empty falls back correctly — but had it used `os.getenv(name, default)`, `""` would have won, `Path("")` is the CWD, and the catalog would have listed nothing on every single install while looking perfectly configured. That is the #1759 trap one seam over (an empty `HOST_TEMPLATES_PATH` making `Path("") / name` an empty named volume), so it is now pinned by a test rather than left as a lucky choice of operator. Docs state the operator contract honestly: the env var alone is not enough, since the directory must also be bind-mounted, and an unreadable path yields an EMPTY catalog rather than an error. Refs trinity-enterprise#126 * fix(systems): harden the manifest read path against its own error reporting Four review findings, each a case where a guard could fail on the input it was guarding. - The unknown-top-level-key warning (added so a `trinity_prompt:`-for-`prompt:` typo stops being silently dropped) sorted raw YAML keys. PyYAML is YAML 1.1: bare `on`/`off`/`yes`/`no` parse as booleans and `2:` as an int, so a mixed-type key set made `sorted` raise TypeError and the catch-all turned it into a raw 500 -- on a manifest that deployed fine before the check existed. Coerce with `str()`. A hygiene check whose purpose is better error reporting must not be able to raise on the input it reports about. - `max_length` counts characters; the stated limit is bytes. Kept as a cheap necessary pre-check and added a byte-exact validator, so the request cap, the bundled reader's `st.st_size` and the UI's `file.size` cannot disagree on a multibyte manifest. - Catalog `reason`s went out as raw `str(e)`. PyYAML parse errors echo the offending source line and `validate_manifest` interpolates manifest values, so route them through `_failure_reason` -- the same credential-sanitizing, userinfo-redacting, length-capping exit the deploy report already uses. The join of N capped reasons needed its own cap. - `_resolve_manifest_path` refused only symlinks escaping the directory, while the listing skips symlinks outright. A symlink pointing *inside* was therefore invisible in the catalog yet readable by id -- "not listed" stopped meaning "not served". Checked pre-`resolve()`, which has already erased the link. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(ui): keep the stale-preview hint reachable and let ?tags= win Two dead paths in the install surface. `invalidatePreview` cleared `previewedText` alongside the preview payload, so the "manifest changed -- preview again" branch could never be true: editing after a successful preview told the user to "Preview first", as if they never had. The marker exists precisely to tell "never previewed" apart from "preview is stale", so it now outlives the payload; only `reset()` (start over) drops it, where "Preview first" is honest again. It cannot re-enable Deploy on its own -- `previewIsCurrent` also requires a non-null `preview`, and that IS cleared. The hint was additionally keyed on `preview` rather than the marker, which made the branch unreachable a second time. `applyDeepLinkFilters` bailed out whenever a system view was active, deferring to it. But `initialize()` restores that selection from localStorage before this runs, so the post-deploy "View this fleet" link silently no-opped for anyone carrying a view from a previous session -- and the `activeFilterTags` watcher would overwrite the tags anyway once views loaded. An explicit `?tags=` now clears the selection, exactly as picking a tag chip does. That is AC #5's whole point: the deploy must not end in a dead end. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(systems): document the bundled-manifest catalog surface The two catalog endpoints shipped without reaching the feature flow at all -- absent from Entry Points, absent from the Router Layer, and their security properties undocumented. Adds both, plus a table of the layered path confinement stating what each layer actually guards against (the character allowlist does NOT reject `..` -- `.` is inside its class -- and the symlink check must precede `resolve()`), the fail-soft contract, and the naming adjacency with `/api/systems/{name}/manifest`, which reads alike and is unrelated. Also reconciles requirements + architecture with this branch's review fixes: the byte-exact manifest cap, the YAML 1.1 key coercion, the sanitized catalog reasons, and the symlink parity check. Adds the missing ent#126 Revision History row, and records the "a hygiene check became the 500 it existed to prevent" pitfall in learnings.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <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.
Summary
This PR addresses two security/housekeeping issues discovered during a security scan analysis:
1. Remove Token Logging from MCP Client (Security Fix)
Problem: The MCP client (
src/mcp-server/src/client.ts) was logging the first 20 characters of JWT tokens and token length on every API request:This could expose sensitive information in production logs (CloudWatch, Datadog, etc.) and potentially aid attackers in token analysis.
Solution:
DEBUG_MCP_CLIENT=trueorNODE_ENV=development)Auth: present/missing)2. Add pytest HTML Reports to .gitignore (Housekeeping)
Problem: Auto-generated pytest HTML test reports (
tests/reports/*.html) were not in.gitignore, potentially leading to accidental commits.Solution: Added
tests/reports/*.htmlto the test artifacts section of.gitignore.Test Plan
DEBUG_MCP_CLIENT=true npm start- debug logs should workNODE_ENV=production npm start- tokens should NOT be loggedImpact