Skip to content

security: Fix token logging and add HTML reports to gitignore - #7

Merged
vybe merged 2 commits into
Abilityai:mainfrom
webmixgamer:security/fix-token-logging-clean
Jan 18, 2026
Merged

security: Fix token logging and add HTML reports to gitignore#7
vybe merged 2 commits into
Abilityai:mainfrom
webmixgamer:security/fix-token-logging-clean

Conversation

@webmixgamer

Copy link
Copy Markdown
Contributor

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:

console.log(`[CLIENT] ${method} ${path} - Token: ${this.token.substring(0, 20)}... (length: ${this.token.length})`);

This could expose sensitive information in production logs (CloudWatch, Datadog, etc.) and potentially aid attackers in token analysis.

Solution:

  • Added environment-aware debug logging utility
  • Only logs in development mode (DEBUG_MCP_CLIENT=true or NODE_ENV=development)
  • Replaced token content logging with simple auth presence indicator (Auth: present/missing)
  • In production: no token information is logged at all

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/*.html to the test artifacts section of .gitignore.

Test Plan

  • MCP Server in development mode: DEBUG_MCP_CLIENT=true npm start - debug logs should work
  • MCP Server in production mode: NODE_ENV=production npm start - tokens should NOT be logged
  • New pytest HTML reports should be ignored by git

Impact

  • Breaking changes: None (fully backward compatible)
  • Risk level: Minimal (localized changes)

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.
@webmixgamer
webmixgamer requested a review from vybe January 17, 2026 15:47
@vybe
vybe merged commit d0db990 into Abilityai:main Jan 18, 2026
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>
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>
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>
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>
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>
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>
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>
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…
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
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants