security: Fix token logging and add HTML reports to gitignore#7
Merged
vybe merged 2 commits intoJan 18, 2026
Merged
Conversation
Previously, the client.ts logged first 20 characters of JWT tokens and token length on every API request, which could expose sensitive information in production logs (CloudWatch, Datadog, etc.). Changes: - Add environment-aware debug logging (DEBUG_MCP_CLIENT or NODE_ENV=development) - Replace token content logging with simple auth presence indicator - In production: no token information is logged - In development: only logs whether auth is present/missing
Prevents auto-generated pytest HTML test reports from being accidentally committed. These reports are development artifacts that should remain local.
oleksandr-korin
added a commit
that referenced
this pull request
Jan 19, 2026
Test Results: - T4.1: Agent error (AGENT_UNAVAILABLE) ✅ - T4.2: Agent timeout⚠️ (bug: step status not updated) - T4.3: Retry policy ✅ - T4.4: Skip on error (on_error:skip_step) ✅ - T4.5: Cancel execution ✅ Key Findings: - Non-existent agent triggers clean AGENT_UNAVAILABLE error - on_error: {action: skip_step} works correctly - Cancel API works immediately BUG FOUND (Issue #7): - Step timeout detected (error.code='TIMEOUT') - But step status remains 'running' instead of 'failed' - Execution doesn't transition to failed state - Impact: Timeout processes may hang indefinitely Running Total: 15/22 tests passing (68%) - ABOVE TARGET ✅ Refs: PROCESS_ENGINE_ROADMAP.md Phase 1
oleksandr-korin
added a commit
that referenced
this pull request
Jan 19, 2026
Test Results: - T4.1: Agent error (AGENT_UNAVAILABLE) ✅ - T4.2: Agent timeout⚠️ (bug: step status not updated) - T4.3: Retry policy ✅ - T4.4: Skip on error (on_error:skip_step) ✅ - T4.5: Cancel execution ✅ Key Findings: - Non-existent agent triggers clean AGENT_UNAVAILABLE error - on_error: {action: skip_step} works correctly - Cancel API works immediately BUG FOUND (Issue #7): - Step timeout detected (error.code='TIMEOUT') - But step status remains 'running' instead of 'failed' - Execution doesn't transition to failed state - Impact: Timeout processes may hang indefinitely Running Total: 15/22 tests passing (68%) - ABOVE TARGET ✅ Refs: PROCESS_ENGINE_ROADMAP.md Phase 1
vybe
added a commit
that referenced
this pull request
Apr 4, 2026
Define 16 structural invariants in architecture.md that must be preserved across changes (layering, DB patterns, router ordering, auth, etc.). Reference them from CLAUDE.md as rule #7 with weekly validation cadence. Add /validate-architecture skill to check codebase compliance. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
8 tasks
This was referenced Apr 20, 2026
Closed
3 tasks
vybe
added a commit
that referenced
this pull request
May 8, 2026
…checks Add governing principle #7 to TARGET_ARCHITECTURE.md: data exchange over conversation chains as the default multi-agent composition pattern. Add Composability category (I-001–I-005) to agent-validation-spec.md: checks that agents declare output contracts, produce structured file-based outputs for downstream consumers, and enforce contracts via post-check hooks. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This was referenced May 11, 2026
4 tasks
oleksandr-korin
added a commit
that referenced
this pull request
May 21, 2026
The chat path (claude_code.py) was missing the _classify_signal_exit call that was added to the headless path (headless_executor.py) for Issue #516. When a SIGKILL terminates the claude subprocess at 0 turns (cgroup OOM, host SIGKILL, watchdog cancel), the chat handler would fall straight into _diagnose_exit_failure, which returns "Subscription token may be expired or revoked. Generate a new one with 'claude setup-token'." even when no auth signal was observed. This misclassification: - Misleads operators into chasing token regeneration when the actual cause is OOM / timeout / external kill - Pollutes the SUB-003 auto-switch trigger pattern matcher (which reads the error string), causing spurious subscription rotations on agents whose subscriptions are provably healthy - Burns the auto-switch 2-hour skip-list slot on phantom auth failures Fix mirrors the existing pattern in headless_executor.py:683 — call _classify_signal_exit first, fall through to _diagnose_exit_failure only for non-signal exits. No new logic; the classifier already produces the honest "Execution terminated by SIGKILL after N tool calls / N turns" message. Adds a structural regression test (parametrized over both files) that pins the call ordering — _classify_signal_exit must appear before _diagnose_exit_failure in both call sites, otherwise the auth-fallback heuristic re-introduces the misclassification. Deployment: requires base image rebuild + agent restart for the fix to take effect on running agents (per CLAUDE.md note #7). Out of scope: Fix 2 (gate auto-switch on observed wire 401/403/429) and Fix 3 (cgroup OOM event reading) — both flagged in #906 as follow-up improvements. Fixes #906 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
12 tasks
oleksandr-korin
added a commit
that referenced
this pull request
May 27, 2026
New `GET /api/agents/{name}/schedules/{schedule_id}/analytics` endpoint
returns counts, success rate, duration p50/p95/p99, cost total, tool-call
top-5 by total wall time, and a UTC daily timeline. Default window 7d
(also 24h / 30d). Inline `ScheduleAnalyticsCard.vue` renders inside
`SchedulesPanel.vue`'s expanded-schedule region — pure CSS, no Chart.js.
Implementation notes (locked by /autoplan + /review):
- Percentiles via `statistics.quantiles(method="inclusive")` over the
newest 5,000 success rows (`_PERCENTILE_ROWSET_CAP`). Counts and
timeline use the full unsampled rowset. `sampled` flag in response.
- Tenant boundary in DB layer (`schedule.agent_name != agent_name`
→ None → 404). `AuthorizedAgent` only validates the URL agent name;
user-supplied `schedule_id` is verified against ownership.
- Tool-call top-5 weighted by `sum(duration_ms)` per tool (not count),
avoiding `Read`/`Bash` dominating low-signal frequency leaderboards.
- Timeline gap-filled Python-side; UTC bucketing via
`substr(started_at, 1, 10)`; documented on the route.
- `window_hours` server-validated to `{24, 168, 720}` → 422 otherwise.
- Soft-deleted schedules return 404 (matches `get_schedule()` policy).
- Frontend uses the shared `api` client (CLAUDE.md invariant #7) and
`useFormatters().formatDuration` composable.
Per-agent rollup and per-chat-session analytics deferred — see issue
body for the destination map (#18 / follow-up).
12 unit tests cover percentile correctness, time-window boundary,
empty + all-running edge cases, NULL duration exclusion, malformed
JSON skip, cross-tenant 404, soft-deleted 404, sampling boundary,
timeline gap-fill, tool-call duration weighting.
CSO diff scan: zero findings (8/10 confidence gate).
Fixes #868
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
6 tasks
vybe
pushed a commit
that referenced
this pull request
Jun 1, 2026
…957) (#976) Avatar Generate dialog showed only "Failed to generate avatar" with no diagnostic info — operators couldn't tell whether the failure was a missing API key, an upstream rate limit, a safety-filter rejection, or a network timeout. Root causes: - Backend returned the raw upstream exception string as the HTTP detail. In several real failure modes (nginx 504 with HTML body, network abort) the frontend got no JSON detail at all and fell back to a hardcoded generic message. - Frontend used bare `axios` instead of the shared `@/api` client (Invariant #7), with no per-status fallback chain. Backend: - `ImageGenerationResult.error_kind` — coarse classification (`not_configured` | `invalid_input` | `safety_filter` | `rate_limited` | `upstream_error` | `timeout` | `unknown`) set on every failure path. - `_classify_exception()` maps httpx + RuntimeError exceptions to a kind. - Catch blocks now use structured logging via `extra={...}` so Vector indexes agent_name, error_kind, exception_type, etc. as fields. - `_AVATAR_ERROR_HTTP` map → kind to (HTTP status, friendly detail). `generate_avatar` and `regenerate_avatar` use the map instead of hardcoded 422 + raw exception text. Service-not-available early-exit uses the same friendly text. Frontend: - `AvatarGenerateModal.vue` switched from bare `axios` to `@/api` and bumped the per-request timeout to 180s (image gen can take >30s). - `describeAvatarError(err, verb)` falls back gracefully on 502/503/504 and no-response cases so the user gets a directional message even when the upstream strips the JSON detail. Tests: - 7 new cases in `tests/unit/test_image_generation_service.py` cover `_classify_exception` and the `error_kind` field default. Related to #957 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
vybe
pushed a commit
that referenced
this pull request
Jun 2, 2026
…Settings (#995) (#996) * feat(enterprise-ui): User & Org Management view on the #847 seam (#995) Public OSS-bundle frontend for the private user_management module (Abilityai/trinity-enterprise#2). Gated entirely server-side by the `user_management` entitlement — hidden in OSS-only builds and bounced by the route guard on direct URL visits. - views/enterprise/UserManagement.vue: org list + create, membership add/remove, seat counts. Light + dark. No algorithmic IP (CRUD glue over the private /api/enterprise/user-management/* endpoints). - stores/orgManagement.js: domain store, calls via shared axios + auth header (Invariants #6/#7). - router: /enterprise/user-management gated meta.requiresEntitlement: 'user_management' (mirrors the audit route). - views/enterprise/Index.vue: add the catalogue card (available). No public backend/schema/model changes — the entire data model + logic lives in the private submodule per the enterprise open-core split. The submodule pointer is intentionally NOT bumped here; it advances after trinity-enterprise#2 merges. Verified: all four files compile; the only build blocker is the pre-existing unrelated `mermaid` import in AgentWorkspace.vue (stale local node_modules; resolved by CI npm ci). Related to #995, #847 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(enterprise-ui): per-user activity audit in Settings → User Management (#995) Integrates the enterprise activity view INTO the existing OSS user management table (not a separate page). When user_management is entitled, each user row gets a "View activity" action opening a drawer with that user's audit summary + timeline, fetched from the private /api/enterprise/user-management/users/{id}/activity endpoint. - Gated entirely by enterpriseStore.isEntitled('user_management') — column + drawer hidden in OSS-only builds. - No change to the existing role-CRUD behaviour; purely additive column. - loadFeatureFlags() in onMounted (cached/no-op when NavBar already ran). Related to #995 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(user-mgmt): OSS deactivation primitive + enterprise lifecycle UI (#995) Pivots #995 from Organizations to the real net-new gap — user onboarding/offboarding — integrated into the existing Settings → User Management table (not a separate page). OSS primitive (edition-agnostic, small): - users.suspended_at column + migration + surfaced in get_user/list_users. - get_current_user rejects suspended users (both JWT + MCP-key paths), so setting the column blocks new logins AND invalidates live tokens on the next request. - /api/users exposes suspended_at (read-only) so the gated UI can render Deactivate/Reactivate. Enterprise UI (gated by user_management entitlement, hidden in OSS): - Settings → User Management gains an "Invite user" form, per-row Deactivate/Reactivate (not for self or the built-in admin), and the per-user Activity drawer. All call the private /api/enterprise/user-management/* endpoints. Removed: the separate /enterprise/user-management Orgs page, its route, store, and Index card (orgs dropped — single-tenant). Index card now points at Settings. No change to the existing OSS role-CRUD behaviour. Verified live: /api/users carries suspended_at; suspend/reactivate/invite/activity all work; OSS-only builds hide every enterprise control. Related to #995, #847 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(user-mgmt): stop Management column clipping in User Management table (#995) The users table wrapper was overflow-hidden; the extra entitlement-gated Management column pushed total width past the card and clipped the right-side action buttons. Switch to overflow-x-auto so the wider table scrolls within the card instead of clipping. Related to #995 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(user-mgmt): fit User Management table in the card (no h-scroll) (#995) Replace the overflow-x-auto stopgap with an actual fit: trim cell padding px-6→px-4 across the table and let the Management actions wrap within their column (flex-wrap, text-xs, no whitespace-nowrap). The 5-column table now fits the max-w-4xl settings card without clipping or a horizontal scrollbar. Related to #995 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test: add suspended_at to schedule-soft-delete test users DDL (#995) The #995 users.suspended_at primitive added the column to _USER_COLUMNS, so get_user_by_*() now SELECTs it. test_schedule_soft_delete builds its own users table with a hardcoded DDL that lacked the column, causing "no such column: suspended_at" (4 regression-diff failures). Mirror the schema change in the test DDL. Related to #995 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(architecture): document enterprise modules + two-track migrations (#995/#997) - users.suspended_at deactivation primitive (OSS column + enforcement; enterprise-only setter) on the users table + a callout. - Invariant #3 extended: enterprise migrates enterprise_* tables via a separate runner tracked in enterprise_schema_migrations (one file per migration; never ALTERs OSS tables; runs after OSS init). - feature-flags doc gains enterprise_features. - New "Enterprise Modules (#847 seam)" section: audit / user_management / siem entitlements, surfaces, and the gating model. Related to #995, #997, #847 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(enterprise): enterprise registration failure must not crash core boot (#995/#997) main.py wrapped register_enterprise(app) in `except ImportError` only — so a bug in enterprise registration (schema init, migration, router mount, pusher start) would propagate and crash backend startup on an enterprise build. Add a broad `except Exception` that logs loudly + a traceback and continues in OSS-only mode. Modules registered before the failure stay active; the rest are simply absent from feature-flags. The core platform always boots. OSS-only builds are unaffected (still the ImportError path). Verified: happy path still boots (health 200) and registers ['audit','siem']. Related to #995, #997, #847 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This was referenced Jun 2, 2026
vybe
added a commit
that referenced
this pull request
Jun 9, 2026
Phase 2 of #740: adds a Loops tab on the Agent Detail page over the existing dev backend (routers/loops.py, loop_service.py) — no backend changes. - stores/loops.js: agent-scoped Pinia store on the shared api.js client (Invariant #7). Filters fleet-wide loop_run_completed/loop_completed WS events by the mounted agent, targeted-refreshes only the affected loop, and runs a 12s backstop poll while any loop is queued/running to recover a missed terminal event. - components/LoopsPanel.vue: Run-loop form (message template w/ {{run}} + {{previous_response}} helper, max_runs, stop_signal, delay, timeout, ModelSelector, allowed_tools), loop list with status/runs/stop_reason, expandable per-run table, last response via DOMPurify renderMarkdown, cooperative Stop control. - AgentDetail.vue: Loops tab between Schedules and Playbooks. - websocket.js: route loop events to the store in the type-keyed branch. - e2e/loops-panel.spec.js + architecture/feature-flow docs. Verified live: tab renders, form submits, loop row reaches terminal state via the live-update path, expanded detail renders the per-run table. Closes #1106 Co-authored-by: Eugene Vyborov <eugene@beingluminous.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This was referenced Jun 9, 2026
Closed
7 tasks
Closed
7 tasks
vybe
added a commit
that referenced
this pull request
Jun 12, 2026
* docs(architecture): add target architecture document and wire into dev workflow
Introduces docs/planning/TARGET_ARCHITECTURE.md — the optimal steady-state
design Trinity should converge toward (PostgreSQL, actor model coordination,
async-first agent communication, fleet observability, GuardAgent security).
Updates CLAUDE.md, DEVELOPMENT_WORKFLOW.md, and the groom/roadmap/sprint
playbooks to distinguish current architecture (what is built today) from
target architecture (where decisions should point), and to use target
architecture alignment as a ranking signal during backlog grooming and
issue prioritization.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(security): encrypt SLACK-001 bot tokens at rest (#453) (#667)
* fix(security): Encrypt Slack bot tokens at rest (#453)
The SLACK-001 public-link Slack integration (`db/slack.py`) was the last
holdout still storing bot tokens as plaintext in SQLite, violating
Architectural Invariant #12. Telegram (`telegram_bindings.bot_token_encrypted`),
WhatsApp (`whatsapp_bindings.auth_token_encrypted`), and SLACK-002
(`slack_workspaces.bot_token`) all already encrypt via `services.credential_encryption`
(AES-256-GCM, JSON envelope). This brings SLACK-001 in line with that pattern.
Additionally, `slack_workspaces.bot_token` was rolled out with lazy
encryption (encrypt-on-write + plaintext fallback at `slack_channels.py:47-49`)
which left two sources of plaintext on disk:
- Rows written before the encryption rollout
- Rows copied from `slack_link_connections` by `_migrate_slack_channel_agents`
A one-shot migration walks both tables on startup and re-encrypts any
plaintext `xoxb-*` rows. Idempotent at row level (skip JSON envelopes)
and at migration level (schema_migrations runner).
src/backend/db/slack.py
- Add `_get_encryption_service` / `_encrypt_token` / `_decrypt_token`
(exact copy of the pattern in `db/slack_channels.py`,
`db/telegram_channels.py`, `db/whatsapp_channels.py`)
- Encrypt at `create_slack_connection` (write site)
- Decrypt at `_row_to_connection` (read site, with `xoxb-*` plaintext
fallback so runtime works pre-migration on legacy rows)
- Caller-facing API surface unchanged — `slack_bot_token` field still
contains plaintext in the returned dict
src/backend/db/migrations.py
- New `_migrate_slack_bot_token_encryption` registered in MIGRATIONS list
- Walks BOTH `slack_link_connections.slack_bot_token` AND
`slack_workspaces.bot_token`, encrypts plaintext rows in place
- Hard-fail on missing CREDENTIAL_ENCRYPTION_KEY (matches the implicit
pattern of every other consumer of CredentialEncryptionService)
- Skips already-encrypted rows (signature: starts with `{` not `xoxb-`)
- Defensive: skips silently if a table doesn't exist
docs/memory/architecture.md
- Reword Invariant #12 to acknowledge channel/subscription tokens as a
documented exception (persisted but mandatorily encrypted), with the
full list of tables under that rule
- Add `slack_link_connections` DDL block; update `slack_workspaces`
block to clarify the column-name vs content-type distinction
tests/unit/test_slack_token_encryption.py (NEW, 14 tests)
- TestRoundTrip: write encrypts, read decrypts, raw DB value is JSON envelope
- TestPlaintextFallback: legacy `xoxb-*` row returns token + warning logged;
corrupt envelope returns None + error logged
- TestEncryptionHelpers: encrypt+decrypt isolation; encrypt raises ValueError
on missing key; decrypt returns None on missing key
- TestMigration: encrypts plaintext in both tables, skips encrypted, idempotent
on second run, hard-fails without key, no-op on missing/empty tables
Live verification on running backend:
- Migration ran on startup: 1 row in each table re-encrypted
(the real `ability.ai` workspace data)
- On-disk now `{"version": 1, "algorithm": "AES-256-GCM", ...}` envelopes
- `SlackOperations.get_slack_connection` returns the original `xoxb-...`
plaintext via decrypt — caller-facing API surface unchanged
Out of scope (filed separately):
- Encryption tests for slack_channels.py + telegram_channels.py +
whatsapp_channels.py (all shipped without dedicated test coverage):
tracked in #664
- Renaming `bot_token` → `bot_token_encrypted` columns: cosmetic, would
require a real schema migration; current naming works behind the
service-layer encapsulation
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(req): annotate SLACK-001 bot token as encrypted at rest (#453)
SLACK-002 (line 809) noted "bot_token encrypted"; SLACK-001 (line 781)
didn't, even after #453 brought slack_link_connections.slack_bot_token
under the same AES-256-GCM regime. Mirror the annotation for parity.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Pavlo <pash@pashs-MacBook-Pro.local>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(announcements): add Cornelius voice mode video announcement
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* docs(validation): add agent validation spec for issue #668
56 checks across 10 categories covering static file checks, YAML/JSON
schema validation, security scanning, and AI-evaluable logical checks
(skill coherence, CLAUDE.md quality, cross-file consistency). Serves as
the canonical check list for the compatibility validation API and MCP tool.
Closes-adjacent: #668
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(monitoring): degrade fleet-status to 'unknown' instead of 500 on NULL status row (#669) (#676)
`GET /api/monitoring/status` (the endpoint MCP `get_fleet_health` calls) was
returning 500 whenever any `agent_health_checks` row had `status = NULL`.
Root cause: the build loop did
AgentHealthSummary(status=check.get("status", "unknown"), ...)
`dict.get(key, default)` only returns the default when the key is *missing*.
A row with the key present but value `None` returned `None`, which Pydantic
v2 rejected because `AgentHealthSummary.status: str` is required.
The sibling per-agent endpoint dodged this by triggering a fresh health check
when no aggregate row existed, which is why `get_agent_health` worked while
`get_fleet_health` 500'd in the production fleet that triggered #669.
Fix:
- Extract `_build_agent_summary(name, check)` and `_coerce_status(raw)` so
NULL/missing/non-str status degrades to `"unknown"` consistently. Same
for NULL `error_message` (would explode `.split("; ")`).
- Wrap the aggregator in try/except returning a structured "unknown" payload
rather than 500 (issue ask #1). Future schema drift surfaces as data, not
as an outage.
- Reconcile `docs/user-docs/operations/monitoring.md` — the listed
`/api/monitoring/fleet-health` path doesn't exist; correct to
`/api/monitoring/status`.
Tests: 7 new unit tests in `tests/unit/test_fleet_status_resilience.py`
covering NULL status, missing status key, missing check row, NULL
error_message, non-string status, and sort-key tolerance.
Does NOT fix the underlying scheduler stoppage (8 of 9 agents going months
without a health-check refresh) — that root cause needs production logs and
is split out as a separate ticket.
Closes #669 (symptom)
Refs #675 (scheduler stoppage follow-up)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(agent-runtime): surface stdout parse failures + orphan identity for #640 debugging (#662)
After diagnosing #640 against a running agent with an npx-launched stdio MCP
(@upstash/context7-mcp@latest), the issue body's "MCP child inherits fd > 2"
theory does not hold: Node.js spawn already isolates the MCP child to fd 0/1/2
on socketpairs. The remaining wire-corruption mechanism (some descendant
outside claude's pgid acquiring agent-server's pipe write-end via setsid+dup)
needs a live production trace to root-cause — single-session repro budget here
isn't enough to manifest the failure (issue says ≥100 turns / 18min).
What this commit does ship: make the next production failure usable by
surfacing the diagnostic data the existing #618/#639 mitigations already had
at hand but discarded.
read_stdout (chat + headless paths)
- JSONDecodeError no longer silently `pass`-swallowed.
- Track count + capture first sanitised, length-capped (300 char) sample.
- Surface in completion log line as parse_failures=N + WARNING with sample
text when N > 0. Lets operators distinguish wire corruption (#640) from
reader-leak-past-claude-exit (#520/#618) in production logs.
_classify_empty_result
- New parse_failure_count / parse_failure_sample kwargs (defaults preserve
legacy callers — chat path doesn't wire it; backward compat covered by
test_default_parse_failure_args_preserve_legacy_callers).
- Detail string now includes parse_failures + raw_messages type histogram
(top 6 types) + first malformed line. The histogram tells operators
whether the reader caught most of the stream or stalled near the start.
_kill_orphan_pipe_writers (#618)
- Was logging orphan count only.
- Now captures cmdline / ppid / pgid per orphan BEFORE SIGKILL (after
the kill /proc/{pid} is gone) and emits one INFO line per pid, capped
at 10 lines + count-only summary, so log volume stays bounded under a
runaway MCP fan-out.
- First pass at identifying which package consistently leaks. Issue
body's "npm setsid" hypothesis is testable now without re-instrumenting.
5 new tests (33 total, all green):
- test_parse_failure_count_surfaces_in_detail
- test_parse_failure_sample_appended_when_present
- test_parse_failure_sample_omitted_when_count_is_zero
- test_raw_messages_type_summary_in_detail
- test_default_parse_failure_args_preserve_legacy_callers
Does not close #640. The wire-interleaving root cause remains open — but
the diagnostic surface is now sufficient to identify it from a single
production failure rather than needing to re-instrument and wait for the
next occurrence.
Issue: #640
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(executions): block SUCCESS over CANCELLED so user cancel survives late agent reply (#671) (#681)
When an operator cancels a running execution mid-flight, two writers race
on the same `schedule_executions` row:
Writer A — terminate handler (`routers/chat.py:~1841`)
writes status = CANCELLED.
Writer B — TaskExecutionService success branch
(`services/task_execution_service.py:498`)
writes status = SUCCESS once the agent's HTTP reply lands. Claude Code
typically catches the cancel signal, emits a graceful final message,
and exits 0 — so the agent reports "completed successfully" and B
lands well after A.
Pre-fix CAS (RELIABILITY-005, db/schedules.py): SUCCESS writes were
unconditional ("agent's own completion result always wins"). When A landed
first and B landed second, B silently clobbered the CANCELLED status with
SUCCESS. Effects in production:
- schedule's `next_run_at` advanced as if the run had succeeded,
suppressing recovery on the next cron tick (silent skip)
- cost telemetry counted the partial run as billable success
- on-call had no signal that deliverables were incomplete
- for agents with side effects (Slack post, sheet rows, CRM), wrongly-
green status hid incomplete work from operators
Reporter saw two consecutive incidents on bdr-agent / `Daily Lead Outreach`
ending with status=success despite the operator cancelling and no Slack
ping / sheet rows / final deliverable being produced.
Fix: narrow the CAS carve-out so SUCCESS writes are blocked when the row
is already CANCELLED, but still win over RUNNING / QUEUED / PENDING_RETRY /
SKIPPED and over a phantom-stale FAILED (preserves the #378 invariant —
real completions still beat misfired Phase-3 cleanup).
- SUCCESS over RUNNING — wins (happy path)
- SUCCESS over phantom FAILED — wins (#378 invariant preserved)
- SUCCESS over CANCELLED — blocked (#671)
- FAILED/CANCELLED over any terminal — blocked (RELIABILITY-005, unchanged)
Tests: 5 unit tests in tests/unit/test_cancelled_not_overwritten.py
covering each transition above. The exact prod race repro
(`test_success_blocked_when_row_already_cancelled`) fails pre-fix, passes
post-fix. Live-verified against running stack:
cancel write ok: True
after cancel: ('cancelled',)
late-success write ok: False
after late-success: ('cancelled', None, None) # response/cost NOT recorded
Defense-in-depth (plumb cancel signal into the agent task-runner so its
reply carries `status=cancelled` and its log says "cancelled by user"
instead of "completed successfully") tracked separately as a follow-up.
Closes #671 (minimum CAS guard)
Refs #679 (defense-in-depth follow-up)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(slack): multi-connection Socket Mode with envelope-ID dedup ring (#244) (#684)
Implements Slack's documented multi-connection Socket Mode pattern in
adapters/transports/slack_socket.py. Slack's edge fans out events across
active connections, so when one half-closes, siblings keep absorbing
traffic — eliminating the 430 ms reconnect gap absorbed by the watchdog.
Empirical basis: 7-day production ops report on ability-services showed
68 disconnect/reconnect cycles, 100% recovered by the watchdog (#278), all
ending in identical "Cannot write to closing transport" — Slack's edge
half-closing without a WebSocket close frame. Slack's own docs and support
team recommend running multiple concurrent connections (up to 10 per app)
as the architectural answer to this exact pattern.
Changes
- N concurrent SocketModeClient instances (default 2; range 1-10) via
SLACK_SOCKET_CONNECTION_COUNT env var, clamped fail-safe on parse error
- _ClientCtx dataclass per client (own watchdog task, own backoff counter)
- Envelope-ID dedup ring (OrderedDict cap 1024 + asyncio.Lock) defends
against possible cross-connection duplicate delivery; INFO log on hit so
we measure whether Slack ever actually dual-delivers
- Per-client log prefix [c=N] so ops can attribute disconnects per client
- is_connected returns "any client healthy" (permissive) + new
connected_count property exposes degraded mode
- stop() iterates all clients/watchdogs (cleanup correctness)
- Parallel start via asyncio.gather keeps boot at ~10s ceiling
- Env-var WARN no longer echoes raw value (prevents accidental token leak
if operator pastes app token into wrong env var)
Tests
- 56/56 passing (28 existing watchdog + 28 new multi-connection)
- New test_slack_multi_connection.py covers env-var bounds,
is_connected/connected_count semantics, dedup ring (skip + concurrent
+ FIFO eviction), per-client backoff isolation, partial startup,
N=1 backward compat, stop() cleanup
Deferred
- #683 — wrap connect_to_new_endpoint() in asyncio.wait_for to prevent
watchdog stall (pre-existing watchdog hole, blast-radius reduced by
this PR's per-client isolation)
Verified
- Single-worker uvicorn confirmed (docker-compose.yml line 82, no
--workers flag; line 230 comment confirms intent), so per-process
dedup ring is correct
- Architectural Invariants preserved (Channel Adapter ABC unchanged;
no new endpoints; no new persistent storage; no Invariant #12 regression)
- /review found 0 critical, 2 informational; C1 applied
- /cso --diff: 0 critical / 0 high / 0 medium / 0 low
Fixes #244
Co-authored-by: Pavlo <pash@pashs-MacBook-Pro.local>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(user-docs): add deployment guides + screenshots
- Add 6 missing deploying/ spokes: local-development, single-server,
public-access, upgrading, backup-and-restore, monitoring
- Add 9 UI screenshots and wire into 10 existing feature docs
- Update deploying-trinity.md hub with spoke navigation table
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(agent-client): circuit breaker cooldown clock no longer resets on failures while open (#687) (#688)
record_failure() was resetting last_failure_time on every call, including
when the circuit was already open. Continuous probe failures (cleanup
re-verify, scheduler dispatches) kept the cooldown timer near zero,
making the half-open transition unreachable and leaving the circuit
permanently open until backend restart.
Fix: only update last_failure_time when state != "open", so the 30s
cooldown starts from when the circuit first opens and is not disturbed
by subsequent failures.
Fixes #687
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* refactor(config): remove legacy unauth REDIS_URL fallbacks (#645) (#697)
`config.REDIS_URL` is the canonical Redis URL gate (#589 / PR #643) —
it raises at import if creds are missing. Three services bypassed
that gate by reading the env var directly with an unauthenticated
localhost fallback, both at the factory site and in the constructor
default:
redis_url = os.getenv("REDIS_URL", "redis://redis:6379")
def __init__(self, redis_url: str = "redis://redis:6379"):
In docker-compose these branches don't fire — REDIS_URL is always
populated. But test/CI paths and ad-hoc debug shells silently fall
back to unauth localhost, then hit NOAUTH at runtime instead of the
clear startup-time error #589 establishes.
Changes:
- slot_service / ssh_service: factories drop the os.getenv fallback;
constructors take Optional[str] = None and lazy-import
config.REDIS_URL when called with no arg.
- capacity_manager: same pattern, lazy-import in __init__.
- tests/unit/conftest.py: setdefault REDIS_URL with creds before any
backend import — unit tests don't share the parent conftest
(norecursedirs = ..) so the env wasn't being primed.
- tests/unit/test_redis_url_no_fallback.py: lint-style regression
test that greps src/backend/services/ for `os.getenv("REDIS_URL"`
and `"redis://redis:6379"` and fails if either resurfaces.
Verified:
- 30 unit tests pass in trinity-backend container
(test_redis_url_no_fallback + test_capacity_manager + test_config_fail_fast)
- 14 ssh_service tests + 9 redis-url-related tests pass on host venv
- Live backend bootstraps cleanly: SlotService / SshService /
CapacityManager all resolve REDIS_URL via config
- Lint test catches regression: stashing the slot_service fix flips
both assertions to FAIL with offending line:number
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(security): close TOCTOU race in webhook rate limiter (#644) (#696)
* fix(security): close TOCTOU race in webhook rate limiter (#644)
Pre-fix path issued a separate GET then INCR. N concurrent callers
could all observe count < WEBHOOK_RATE_LIMIT before any of them
incremented, slipping past the 429 and pushing the actual call rate
to limit + N.
Switched to INCR-then-compare (Redis INCR is atomic): increment
unconditionally, then 429 the caller whose post-increment count crosses
the threshold. Trade-off: blocked requests still tick the counter,
slightly extending cool-down for an over-limit token. Acceptable for
a rate-limiter — we only stop accepting work, we don't unwind.
Tests:
- tests/unit/test_webhook_rate_limit_toctou.py — pins INCR-first
semantics. The structural assertion (r.get() not called) reliably
catches a partial revert that re-adds the GET; the wide-window race
belongs in integration tests against real Redis.
- tests/integration/test_webhook_rate_limit.py — adds concurrent
burst test alongside the existing #589 sequential coverage.
Verified live in trinity-backend with real Redis:
- pre-fix: 15/20 succeeded under 20-thread burst (limit 10) — race
reproduced.
- post-fix: exactly 10/20 succeeded — limit holds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(webhooks): unblock trigger endpoint — schedule model + audit signature (#647 follow-up)
While verifying #644 against a live stack, found two additional facade
gaps that #648 (the WEBHOOK-001 delegation fix) didn't catch — both
crash trigger_webhook before the rate-limiter even runs to completion:
1. `Schedule` pydantic model never carried `webhook_enabled` /
`webhook_token` fields. The DB columns exist, but the row mapper
discarded them, so `if not schedule.webhook_enabled:` raised
AttributeError on every trigger call.
2. `webhooks.py:trigger_webhook` called `platform_audit_service.log()`
with `actor_type="system"`. The service derives actor_type
internally from actor_user / actor_agent_name / mcp_scope and has no
such kwarg; every accepted webhook 500'd in the audit step.
Both are tiny:
- Add the two fields to `Schedule` (db_models.py).
- Pull them through `_row_to_schedule` (db/schedules.py).
- Drop the bogus actor_type kwarg, pass actor_ip instead — webhook
callers are unauthenticated so caller IP is the only attributable
signal.
With these, the integration test in tests/integration/test_webhook_rate_limit.py
now exercises the full HTTP path end-to-end. Live verification against
the running backend: 15-way concurrent burst → 10 × 202 + 5 × 429,
limit holds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(api-keys): clipboard fallback + error feedback (#677) (#695)
Both copy actions on /api-keys (Copy Config, Copy key icon) called
navigator.clipboard.writeText() with no fallback and swallowed every
rejection in console.error, so users in modal-focus or non-secure
contexts saw nothing on the clipboard and no error.
- Add `utils/clipboard.js` with a textarea + execCommand fallback,
returning a boolean for caller-driven UX.
- Wire ApiKeys.vue's copyApiKey / copyMcpConfig through the helper.
Visual "Copied!" / green-check state only fires on confirmed
success; failure shows an alert telling the user to copy manually.
- Add e2e spec exercising both buttons with a granted
clipboard-read permission.
Verified in a real browser (admin login, /api-keys, create key,
click both buttons): clipboard contained the expected MCP JSON and
raw `trinity_mcp_*` key respectively; no console errors.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(monitoring): correct get_accessible_agents call signature (#682) (#694)
`GET /api/monitoring/status` returned 500 TypeError for non-admin users
because routers/monitoring.py called the helper with the pre-refactor
two-arg signature `(email, agent_names)`. Every other call site was
updated to `(current_user)` — only this one was missed.
Adds a unit regression test that pins the canonical helper signature
and spy-verifies the router calls it with exactly one positional
User arg.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(harness): repro scaffolding + negative results for #640 — 6 hypotheses falsified (#693)
* test(harness): repro scaffolding for #640 — controlled stdio MCP leaks
Adds tests/harness/640/ — three files implementing a deterministic repro
harness for the open root cause behind the reader-thread / wire-corruption
failure family (#640, manifesting in #678, #630, #618, #548, #586).
Background: PR #662's author tried 1.5h with `@upstash/context7-mcp` —
issue body says ≥100 turns / 18min are needed to manifest. Hunting for a
naturally-leaky package is unreliable. Instead this harness builds a
controlled experiment: a minimal stdio MCP server with switchable leak
variants, each testing one hypothesis from the issue body and #662's
empirical notes.
Files:
- noisy_mcp_server.py — stdlib-only stdio MCP server with --leak knob:
none : control / baseline
stderr-flood : MCP child stderr noise
setsid-child : grandchild that escapes pgid (#618 family)
and retains protocol-pipe write end
proc-fd-write : raw writes to /proc/self/fd/1 interleaved
with MCP frames
delayed-stdout : partial-line writes that race the reader at
line boundary
npm-wrapper : real-world npx-style boilerplate emitted to
stdout BEFORE protocol handshake — most likely
production culprit
- run_repro.py — driver that hits an agent's chat API for N turns and
measures null-cost / null-response rate (the observable symptom from
#678). Exits 1 if rate exceeds --null-cost-fail-rate (default 5%) so
the harness can also serve as a CI regression gate once a fix lands.
- README.md — runbook: agent setup, .mcp.json wiring, expected output,
caveats. Documents that parse_failures-counter assertions wait on
PR #662 merging.
Smoke-tested:
- Simulator's CLI parses --help.
- Sample initialize + tools/list session round-trips clean JSON
responses with --leak=none, sidecar log captures protocol activity.
- npm-wrapper variant emits the boilerplate BEFORE the JSON-RPC reply
as designed.
This commit is scaffolding only — actual variant characterization
against a running stack is a follow-up. Each variant takes ~10-15 min
of Sonnet wall-time at 50 turns, so running the full 6-variant matrix
is a budgeted exercise rather than something to do in one CI pass.
Refs #640
Refs #662 (parse_failures instrumentation prerequisite)
Refs #678 (production manifestation that motivated this work)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(harness): document negative results for #640 — 6 hypotheses falsified
Adds the results section to tests/harness/640/README.md after running 2
variants empirically (npm-wrapper, setsid-child) plus 4 hypotheses ruled
out via static inspection of claude-cli's cli.js and Linux kernel
behaviour. Also fixes a driver bug where cost was read from
top-level `cost_usd` instead of `metadata.cost_usd` (real cost lives
under metadata; the chat endpoint nests observability fields there).
The harness as designed cannot reproduce #640 because every leak path
it can exercise is on the MCP protocol pipe (claude-side), not on the
agent-server claude-stdout pipe (which is where the wire corruption
in #640 actually manifests). Claude+SDK isolate MCP child stdio
correctly; Linux refuses /proc/*/fd/* bypass with ENXIO; Claude has
no stray stdout writes in stream-json hot path.
Negative results preserved so future #640 hunts don't re-walk the
same paths. Real next step is landing PR #662 and getting prod-data-
driven evidence on which package actually leaks.
Refs #640
Refs #662 (diagnostic instrumentation prerequisite)
Refs #678 (production manifestation)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(voice): agent workspace page with canvas panel and Gemini panel tools (#699) (#703)
* feat(voice): agent workspace page with canvas panel and Gemini panel tools (#699)
Adds a full-page voice workspace at /agents/:name/workspace with a split
layout: orb + controls on the left, an agent-controlled canvas panel on
the right. Introduces four in-process panel tools (show_markdown,
update_panel, append_to_panel, clear_panel) that let Gemini write
structured content to the canvas during a voice conversation without
delegating to the agent container. Panel state is polled at 300ms via
a new GET /voice/{session_id}/panel endpoint. Workspace mode is gated
on a new voice_available feature flag (GEMINI_API_KEY + VOICE_ENABLED)
and surfaced via a BETA-badged button in AgentHeader.
Security: panel content is DOMPurify-sanitised before v-html rendering;
panel endpoint has session ownership checks; append_to_panel caps
accumulated content at 512 KB to bound per-session memory.
Tests: 7 new panel-tool unit tests (test_voice_tools.py); 5 new panel
endpoint ownership tests (test_voice_auth.py).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* docs(voice): add VOICE-008 to requirements + voice API table in architecture
Adds VOICE-008 (Voice Workspace / #699) requirements entry and Phase 4
roadmap entry. Adds voice API endpoint table to architecture.md (was
entirely absent) and updates feature-flags description to mention
voice_available.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(voice): cross-worker session 403 + audit kwargs TypeError (#704 #705) (#706)
#704: Voice sessions written to Redis (dual-write with in-memory) so a
WebSocket worker can auth-check a session created on a different Uvicorn
process. VoiceService.create/get/remove_session are now async; Redis key
TTL = VOICE_MAX_DURATION + 60 (360s); Redis failure at /voice/start raises
loudly rather than silently producing an intermittently-failing session ID.
#705: on_tool_call callback passed legacy actor_type=/actor_id=/actor_email=
kwargs that don't exist on platform_audit_service.log(), causing a TypeError
that silently swallowed every voice tool-call audit record. Fixed to use
actor_user=types.SimpleNamespace(id=..., email=...) matching the _resolve_actor
contract; wrapped in asyncio.create_task so the audit write doesn't block.
Tests: +7 Redis fallback tests (TestRedisSessionFallback in test_voice_tools.py),
+1 audit attribution source-inspection test (TestVoiceAuditAttribution in
test_voice_auth.py). Catalog updated: 45 voice unit tests total.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(voice): workspace panel flicker + Chart.js rendering in update_panel (#707) (#709)
- updated_at change-detection gate in fetchPanel() prevents 3x/sec Vue
re-renders and stops empty state from overwriting content when session ends
- in-flight guard (panelFetching flag) prevents overlapping 300ms requests
- panel content preserved on session end; reset on new session start
- replace v-html+sanitizedHtml computed with ref+renderHtmlPanel():
DOMPurify.sanitize(html, {ADD_TAGS:['script']}) + _execScripts() re-clones
script nodes as live DOM so Chart.js new Chart() calls execute correctly
- Chart.js 4.4.0 pre-loaded via injectChartJs() on mount (CDN, id-guarded)
- WORKSPACE_PANEL_INSTRUCTIONS updated: document Chart.js pre-loaded rule
Fixes #707
Co-authored-by: Claude <noreply@anthropic.com>
* docs(architecture): update stale api-keys refs after #302 settings refactor
ApiKeys.vue deleted; /api-keys now redirects to /settings?tab=mcp-keys.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* refactor(settings): tabbed layout with role-gated MCP Keys absorption (#302) (#700)
Splits the 2,600-line Settings page into 5 logical tabs (General, Access,
Integrations, MCP Keys, Agents) with URL ?tab= deep-linking and ROLE-001
role-gated visibility. Absorbs the standalone /api-keys page into the new
MCP Keys tab; preserves bookmarks via a permanent SPA redirect.
Built test-first via Canon TDD against a 12-behavior list documented at
docs/planning/302-settings-test-list.md. 14 Playwright tests pass; 13
match the test-list items 1:1 plus 1 regression test pinning the
non-admin admin-403-bounce fix surfaced during /review.
Behavior
- /settings ?tab=<id> deep links to any tab. Unknown ?tab= falls back to
the user's default tab. Browser back/forward navigates tab history.
- Tab visibility gates by role: admin sees all 5 tabs; non-admin sees
only MCP Keys (matches today's /api-keys page being non-admin).
- Default tab: General for admin, MCP Keys for non-admin.
- /api-keys redirects to /settings?tab=mcp-keys (static literal target,
no open-redirect surface).
- NavBar "Keys" link removed; Settings link now visible to all auth
users (was admin-only).
Implementation
- New components/settings/McpKeysTab.vue extracted from views/ApiKeys.vue
(deleted). Same auth posture preserved verbatim.
- New composables/useRole.js mirrors backend ROLE-001 hierarchy
(user < operator < creator < admin) for client-side UI gating.
- New authStore.role getter sourced from /api/users/me; new
fetchUserProfile() action populates role on login + session restore.
- 13 existing Settings sections wrapped with v-if matching their tab.
- watch(isAdmin, ..., { immediate: true }) guards admin-only data
fetches so non-admin users don't trigger 403 → router.push('/')
bounce — this was the bug surfaced by /review and fixed pre-merge.
Security
- Backend require_admin/require_role in routers/settings.py UNCHANGED.
UI hiding is convenience, not the security boundary. A non-admin who
edits localStorage.user.role = 'admin' sees all 5 tabs but every admin
endpoint still returns 403 — UI bypass yields zero capability gain.
- /api-keys redirect target is a hardcoded literal — no user input.
- /review: 0 critical (after C1 fix), 5 informational.
- /cso --diff: 0 critical / 0 high / 0 medium / 0 low.
Acceptance criteria status
- [x] Tabbed nav with ?tab= URL query param
- [x] 12+ sections organized into 5 logical tabs
- [x] MCP API Keys absorbed into Settings
- [~] "Each tab a separate Vue component" — only McpKeysTab.vue
extracted; the other 4 tabs remain inline v-if sections in
Settings.vue. Follow-up issue worth filing for full extraction
(state, methods, computed props need to move per-tab).
- [x] NavBar simplified (Keys link removed)
- [x] Non-admin users still access MCP key management
- [x] No functionality lost — covered by behavior 11 regression test
Test plan
- 14/14 settings-tabs.spec.js pass (13 list-driven + 1 regression)
- Existing smoke.spec.js updated (no longer asserts Keys link)
- 3 unrelated session-tab.spec.js failures pre-exist on dev (unaffected)
Out of scope (intentionally deferred)
- Full tab-as-component extraction (4 remaining)
- v-show vs v-if for modal-state preservation across tab switches
- Vitest unit-test infra (frontend has Playwright e2e only)
Fixes #302
Co-authored-by: Pavlo <pash@pashs-MacBook-Pro.local>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(voice): test regression + Chart.js bundling + model default (#723) (#726)
Fixes three independent regressions in the voice workspace:
1. **test_voice_auth.py collection failure** — `_stub_docker_service()` was
missing `docker_client` and four other attrs that `services/__init__.py`
imports; adding a template_service stub also prevents a cascade from
migration code that imports `services.credential_encryption`.
Set `SECRET_KEY` env var early so the real config and `test_voice_tools.py`
stub both sign/verify JWTs with the same key (avoids 4001 on ownership tests).
2. **test_voice_tools.py import error** — `services.gemini_voice` stub left in
`sys.modules` by `test_voice_auth.py` blocked the real import of
`GeminiVoiceService`. One-line eviction before the import fixes it.
3. **Chart.js CDN → bundle** — replace the dynamic CDN script injection with a
proper `import Chart from 'chart.js/auto'` + `window.Chart = Chart`
in `AgentWorkspace.vue`. The CDN approach was unreliable under load;
the bundled path is deterministic. `fetchPanel` null-guard updated so
panel content is never overwritten by an empty response after session end.
Also exposes `VOICE_MODEL` env var in docker-compose and updates the default
model identifier to `models/gemini-3.1-flash-live-preview`.
All 45 tests in test_voice_auth.py + test_voice_tools.py pass.
Closes #723
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(schedules): restore auth parity on GET webhook status endpoint (#724) (#727)
* docs(validate-pr): add infrastructure change check to align with DEVELOPMENT_WORKFLOW.md
Adds Step 4.7 to flag docker-compose/Dockerfile changes without justification,
matching the Red Flags checklist in docs/DEVELOPMENT_WORKFLOW.md.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(schedules): restore auth parity on GET webhook status endpoint (#724)
GET /{name}/schedules/{id}/webhook used AuthorizedAgent which calls
db.get_agent_owner() and 404s for agents not in the ownership table.
POST and DELETE already use name:str + can_user_access_agent; align GET
to match so all three webhook endpoints behave consistently.
Fixes #724
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(tests): fix test import drift — 0 collection errors, all 3 target files pass (#725) (#729)
* fix(tests): patch sys.modules stubs in monitoring router and skill service user agent tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(tests): add get_agent_default_resources stub to readiness probe test (#725)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(tests): recover real fastapi in monitoring router loader (#725)
test_inject_assigned_credentials.py permanently overwrites sys.modules['fastapi']
with a Mock at collection time via sys.modules.update(). When collected after that
file (alphabetically 'i' < 'm'), _load_monitoring_router() exec'd monitoring.py with
a mocked APIRouter, causing @router.get() to return a Mock instead of the original
async function. asyncio.run() then raised TypeError on the Mock.
Fix: briefly evict the polluted fastapi entry, re-import from disk to get the real
module, restore the Mock, then include the real fastapi in the patch.dict context
used during exec_module.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(agent-server): cap drain executor thread at 90 s to fix #728 CPU spin (#730)
`safe_close_pipes` deadlocks on Python's BufferedReader internal lock when
a subscription token is expired (no claude child ever spawns, reader threads
stay alive). The deadlock wedges `asyncio.run(_drain_reader_threads(...))` in
the executor thread for up to 7200 s at 87–91% CPU.
Add `_drain_bounded()` in `claude_code.py`: runs the drain inside a daemon
thread with a `threading.Event` + 90 s `done.wait()` budget. Replaces all 4
`asyncio.run(_drain_reader_threads(...))` call sites. `subprocess_pgroup.py`
is untouched to minimise regression risk.
4 unit tests in `tests/unit/test_drain_bounded.py`.
Fixes #728
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* test(settings): harden #302 e2e coverage + add manual test plan (#716)
Follow-up to PR #700 (issue #302). Adds 6 new Playwright tests and a
manual test runbook to lift coverage from "navigation works" to
"regression-safe + integration".
New e2e tests (in src/frontend/e2e/settings-tabs.spec.js):
F1. Every section appears under its expected tab — replaces the limited
behavior 11 (4 of 13 sections) with full 13-section regression. Uses
exact:true match to disambiguate "API Keys" from "MCP API Keys".
F2. Parametric deep links — 4 ?tab= IDs not already covered by behavior 2
(general, access, integrations, agents). Round out the 5-ID matrix.
F3. MCP Keys CRUD integration — drives create + revoke through the UI,
verifies state via API queries. Uses CLEANUP_PREFIX 'test-302-e2e-
cleanup' for the test key name. afterEach hook + DELETE response
assertion cleans up; manual recovery one-liner documented in the
file header in case the hook somehow misses (zero stray rows
observed in local runs).
F4. Admin login fetches /api/users/me — pins the new fetchUserProfile()
wiring from auth.js so a future refactor can't silently break role-
based UI gating.
F5. Re-click active tab does not push duplicate history — guards against
regressing the early-return guard in selectTab().
F6. Non-admin does not see MCP Server URL section in MCP Keys tab —
confirms the inner v-if="isAdmin" gate (independent of the tab-level
v-if) works.
Test infra changes
- Whole spec file marked test.describe.configure({ mode: 'serial' })
because the CRUD test creates real keys and parallel workers race
against the McpKeysTab list re-fetch + ensureDefaultKey side effects
in headless mode. Total runtime: ~12s serial vs ~5s parallel —
acceptable.
- New cleanupTestMcpKeys helper used by afterEach hook.
- New CLEANUP_PREFIX constant exported in the file header comment with
the docker-exec recovery one-liner.
Manual test plan (docs/testing/302-settings-tabbed-layout-manual-test-
plan.md): 7-section runbook covering everything the e2e suite covers
plus the things only humans can verify (visual UX, real non-admin
session via DevTools localStorage spoof, /api-keys bookmark redirect
demo). ~25 minutes for full pass.
Verified
- 23/23 settings-tabs.spec.js pass in 12.2s
- 0 stray test rows in mcp_api_keys after a full run
- No backend, docker, or CI changes — this is purely test-infrastructure
hardening for a frontend-only feature
Refs #302
Co-authored-by: Pavlo <pash@pashs-MacBook-Pro.local>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(slack): startup recovery supervisor for transient initial-connect failures (#708) (#719)
When ALL initial Socket Mode connect attempts fail at backend boot
(transient DNS slowness, edge throttle, etc. exceeding the 10s connect
ceiling), `start()` now spawns a recovery supervisor task that retries
in the background with the watchdog's exponential backoff (60→120→240→
300s cap) until at least one client connects, then graduates to the
per-client watchdog model.
Pre-fix behavior: silent permanent-offline state until manual restart.
The watchdog model assumed at-least-one-connection, leaving no path
out of "zero contexts" once initial gather failed.
Behavior matrix:
- All initial succeed → no supervisor, watchdogs run (no overhead change)
- Partial succeed → no supervisor, degraded mode unchanged
- All initial fail (transient) → supervisor retries, exits on recovery
- All initial fail (bad creds) → supervisor retries forever; backend
HTTP stays fully responsive; ERROR "STARTUP UNREACHABLE" log fires
after 3 consecutive failures for operator paging
- Token format invalid (no xapp- prefix) → existing early-return path
preserved, no supervisor (permanent error must not spin)
- stop() called mid-supervisor → supervisor cancelled cleanly, await
propagates CancelledError, no zombie task
main.py: stop nilling _slack_transport when initial connect fails so
the supervisor task isn't orphaned.
Test coverage: 10 new unit tests (TestStartupRecoverySupervisor) +
1 flipped existing (test_start_aborts_when_all_clients_fail →
test_start_spawns_supervisor_when_all_clients_fail). 65/65 pass.
End-to-end smoke verified against running backend with extra_hosts
DNS poisoning + bad-credentials scenarios; backend HTTP confirmed
responsive throughout.
Co-authored-by: Pavlo <pash@pashs-MacBook-Pro.local>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(config+sec): align TRINITY_PASSWORD across compose files + close changeme propagation paths (#692)
- docker-compose.yml: TRINITY_PASSWORD now reads ADMIN_PASSWORD directly
- docker-compose.prod.yml: switch to fail-loud ${ADMIN_PASSWORD:?...} on both backend and mcp-server
- .env.example: collapse duplicate FRONTEND_URL; add GOOGLE_API_KEY, LOG_*, TRINITY_DATA_PATH, HOST_TEMPLATES_PATH
- src/mcp-server/src/server.ts: drop || "changeme" fallback; throw on startup when MCP_REQUIRE_API_KEY=false and no usable credential
- scripts/deploy/gcp-deploy.sh: refuse to deploy if ADMIN_PASSWORD is unset or literally "changeme"
- deploy.config.example: drop "changeme" default
- docs: update mcp-orchestration flow, single-server deploy guide, TEST_REPORT historical note, feature-flows index
Default MCP_REQUIRE_API_KEY=true mode is unaffected.
Closes #692
Co-Authored-By: AndriiPasternak31 <andriipasternak31@gmail.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(tests): green the unit-test suite (closes #660) (#714)
Resolves all 49 failures + 2 collection errors in `uv run pytest tests/unit/`.
Final state: 754 passed, 1 skipped, 0 failed across 3 consecutive runs.
Production schema/migrations and backend code are untouched.
Failure groups fixed:
- A (14): missing backend deps in tests/requirements-test.txt
- B (4): test_telegram_webhook_backfill missing platform_audit_service stub
- C (2): test_fleet_sync_audit S7 partial UNIQUE index prevents seeding duplicate-binding state
- D (28): test_file_upload cascading pollution from test_backlog tmp_db + test_slack_watchdog sys.modules stub
- E (4+1): test_git_status_dual_ahead_behind inverted args + agent_server package shadow
- F (1): test_agent_server_auto_sync same package shadow as E
Also adds slackify-markdown>=0.2.0 floor for supply-chain consistency.
Co-Authored-By: Andrii Pasternak <andriipasternak31@gmail.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(schema): backfill schema.py to match migrations.py reality (#691) (#712)
Adds 11 tables, 14 columns, and ~22 indexes that existed only in
migrations.py to schema.py, restoring it as a faithful reference for
the database (Architectural Invariant #3).
Mechanical and additive. Migrations are append-only history. Existing
databases unaffected — every new statement uses IF NOT EXISTS.
Tables added: subscription_credentials, agent_notifications,
subscription_rate_limit_events, slack_workspaces, slack_channel_agents,
slack_active_threads, telegram_bindings, telegram_chat_links,
telegram_group_configs, whatsapp_bindings, whatsapp_chat_links.
Columns added: agent_ownership (full_capabilities, max_backlog_depth,
voice_system_prompt), schedule_executions (source_user_id,
source_user_email, source_agent_name, source_mcp_key_id,
source_mcp_key_name, claude_session_id, queued_at, backlog_metadata,
fan_out_id), agent_schedules (webhook_token, webhook_enabled).
Indexes added: subscription, notification, rate-limit, multi-agent
slack, telegram, whatsapp, plus partial indexes for execution backlog
(idx_executions_queued), retry (idx_executions_pending_retry),
fan-out (idx_executions_fan_out), webhook tokens
(idx_schedules_webhook_token), and proactive sharing
(idx_agent_sharing_proactive).
Verified: name-only and strict DDL parity scripts both pass with
zero missing and zero different entries. test_migrations.py 17/17.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(metrics): add /metrics skill for engineering analytics
Ports the metrics skill from feature/704-705-voice-bugs to dev.
Provides velocity, cycle time, bug ratio, and backlog health reporting
via GitHub Issues + project board data. Includes the 2026-05-07 baseline
report generated during initial development.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* docs(validation-spec): add F-011/F-012/F-013 file structure checks for architecture, requirements, and changelog docs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* docs(arch+validation): add data-exchange principle and composability 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>
* chore(metrics): code-health baseline 2026-05-08 @ 1eb7b5e
* feat(code-health): add /code-health skill and weekly schedule on trinity agent
- Add code-health skill playbook (.claude/skills/code-health/)
- Run first code health baseline: top hotspot routers/chat.py (score 6105),
14 size violations, 6 stale TODOs, 0 circular imports
- Commit baseline to docs/metrics/code-health-baseline.json
- Document /code-health in DEVELOPMENT_WORKFLOW.md (checklist + commands table)
- Schedule weekly Monday 09:00 UTC autonomous run on trinity agent
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(git): use per-agent PAT fallback in github sync, helpers, and lifecycle (#735) (#739)
Three callsites were ignoring per-agent GitHub PATs and calling the
platform-only get_github_pat() directly, causing silent clobber of
per-agent PATs on container restart and preventing agents with per-agent
PATs from initializing git sync without a platform PAT also configured.
- routers/git.py initialize_github_sync: get_github_pat() → get_github_pat_for_agent(agent_name)
- services/agent_service/helpers.py check_github_pat_env_matches: platform PAT → agent-effective PAT (prevents spurious recreation)
- services/agent_service/lifecycle.py: platform PAT update → agent-effective PAT (prevents per-agent PAT clobber on restart)
19 unit tests added (static callsite checks + logic tests for fallback chain).
Fixes #735
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(schedules): prevent accidental re-enable via MCP update and expose retry config fields (#741) (#742)
Two silent bugs in MCP schedule update path:
1. Terse `enabled` description in `update_agent_schedule` led AI models to
include `enabled: true` when updating unrelated fields, re-enabling
schedules the user had intentionally disabled. Added explicit warning to
omit the field unless changing state is intended.
2. `ScheduleUpdateRequest` was missing `max_retries` and
`retry_delay_seconds`, so Pydantic's `exclude_unset=True` silently
dropped those fields before they could reach `db.update_schedule()`.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(subprocess): replace os.stat() with os.readlink() in orphan pipe scan (#728) (#747)
os.stat() on /proc/pid/fd/N follows the symlink to the pipe inode and
acquires an inode lock at the kernel level. On a D-state process this
lock may be held indefinitely, causing _kill_orphan_pipe_writers to
silently exit its 10 s daemon-thread cap without ever finding the orphan
writer — leaving the reader thread permanently leaked.
os.readlink() reads the symlink target string "pipe:[inode]" from the
proc pseudo-filesystem's own metadata WITHOUT following the symlink and
WITHOUT acquiring any inode lock. Safe on D-state processes.
Changes:
- Replace os.stat() + fdinfo flags check with os.readlink() + "pipe:[N]" match
- Add our_pid = os.getpid() self-skip (handles our_pgid=None edge case)
- Remove fdinfo write-flag check (no longer needed once self is excluded)
Regression test: TestKillOrphanPipeWriters.test_kills_orphan_even_when_stat_raises_dstate_simulation
monkeypatches os.stat to always raise OSError, verifies orphan is still
found and killed. Would have FAILED against the old implementation.
Complements the _drain_bounded 90 s cap from PR #730.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* docs(adr): evaluate Claude Agent SDK migration (#409) (#743)
Recommends DEFER. SDK does not close #285/#407 by design and
introduces five observability regressions plus an unverified
subscription-token auth path. Proposes completing #122 (split) on
the current architecture instead, with a rescoped 6-module target
that matches today's 2137-line file.
Closes #409
* ci(unit-suite): add per-PR + nightly regression gates (#715) (#744)
* ci(unit-suite): add per-PR + nightly regression gates (#715)
Two GitHub Actions workflows that surface the dev-merge residual class
of regressions earlier than reviewer time:
- backend-unit-test.yml — per-PR gate. Runs the unit suite under three
pytest-randomly seeds against both the PR's base-branch tip and its
merge commit, then diffs the union of failing test IDs. Fails the
check on new failures or missing JUnit XML (fail-closed on infra).
- backend-unit-nightly.yml — cron 06:00 UTC sweep over open PRs
targeting dev. Three jobs: discover (RO) → test (RO, no creds, runs
untrusted PR code via pull/N/head) → comment (write, no checkout).
Posts sticky regression comments via github-script.
scripts/ci/diff-pytest-failures.py — JUnit XML diff utility. Tracks
<failure> and <error> kinds separately, fails closed on missing or
unparseable XML, ships an in-process --self-test mode (8 cases) that
the workflows run before the real diff.
pytest-randomly is installed in-workflow only — adding it to
tests/requirements-test.txt would silently randomize tests/run-core.sh
because pytest auto-discovers installed plugins.
Out of scope (per #715): fixing the 34 failures + 17 errors in the
existing baseline. Those belong to the #660 follow-up; this gate is
the entry point that surfaces them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* ci(unit-suite): stash diff script before base-side checkout (#715)
The base-side matrix legs were failing fast because
scripts/ci/diff-pytest-failures.py only exists on the PR branch — when
we git switch --detach onto the target-branch tip for the base run, the
file disappears and the self-test step exits 2 ("No such file").
Fix: copy the script to ~/.ci-tools/ in the merge-commit checkout
before any side switch, then run the self-test against the stashed
copy. The pytest run itself doesn't need the script (it just produces
JUnit XML); only the diff aggregator job consumes it, and that job
checks out the PR branch fresh.
Caught immediately by the gate validating itself on PR #744 — exactly
the dev-merge-residual feedback loop the gate is meant to provide.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* ci(unit-suite): harden nightly comment loop against marker quoting and bad JSON (#715)
Two issues from the focused SQL/race/auth review:
1. Sticky-comment finder lacked an author filter. A user reply that
quoted the `<!-- nightly-unit-suite -->` marker would match
`existing.find(...)`, and the subsequent updateComment call would
403 (bots can only edit their own comments) — leaving the PR
without a fresh sticky comment. Now requires `c.user.type === 'Bot'`
or `github-actions[bot]` author.
2. One malformed status JSON in the matrix would JSON.parse-throw out
of the for-loop, killing comment posting for every subsequent PR
in the same nightly run. Each iteration now lives in its own
try/catch and logs a per-PR warning on failure.
Both caught before nightly ever fired in production. Per-PR gate is
unaffected (the bug was only in the nightly comment job).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* ci(unit-suite): use trusted dev-branch diff script for nightly tamper-evidence (#715)
Defense-in-depth from the /cso --diff audit. Previously the nightly's
self-test and regression-diff steps invoked
scripts/ci/diff-pytest-failures.py from the merged-in PR workspace,
meaning a malicious PR could ship a modified diff utility that always
exits 0 and produces a false "✅ Nightly clean" sticky comment.
Fix: stash the trusted copy from origin/dev before the merge, then
invoke ~/.ci-tools/diff-pytest-failures.py for both --self-test and
the real regression diff. The stash step happens at position 2 (right
after the dev checkout, before pull/N/head fetch + merge), so the
file copied is always the unmerged dev-branch version regardless of
PR contents.
The per-PR gate is intentionally not hardened the same way — that
gate is a self-check, and a PR that modifies its own diff script
shows up conspicuously in the diff for the human reviewer. The
nightly is the cross-PR signal that warrants tamper-evidence.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* ci(schema): schema.py vs migrations.py parity gate (#713) (#745)
* ci(schema): add schema.py vs migrations.py parity gate (#713)
Adds a pytest unit test (`tests/unit/test_schema_parity.py`) plus a
path-filtered GitHub Actions workflow (`.github/workflows/schema-parity.yml`)
that fails any PR whose `init_database()` boot-path produces tables,
columns, indexes, or triggers that aren't declared in `init_schema()`
alone.
The check builds two in-memory SQLite snapshots — `init_schema()` only,
and the full `migrations -> init_schema -> migrations` lifecycle from
`database.py:139-164` — and diffs them. The full lifecycle on an empty
DB is required so short-circuit migrations like `_migrate_audit_log_table`
(`migrations.py:1356-1364`, returns early when `audit_log` exists) can't
hide schema.py omissions of indexes/triggers.
Per-column TYPE comparison via PRAGMA table_info also catches drift
where schema.py and migrations disagree on a column's type — a class of
bug that name-only diffing would miss.
Out of scope, documented in the test docstring: reverse-direction drift
(schema.py edited without a migration), constraint-level drift
(NOT NULL / DEFAULT / FK / CHECK), trigger BODY drift. Future work.
Closes the prevention loop for #691: PR #712 backfilled 11 tables, 14
columns, and ~22 indexes that had drifted out of schema.py over time.
This gate stops the same drift from re-accumulating.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(schema-parity): catalog new test + harden CI key placeholder (refs #713)
- .claude/agents/test-runner.md: add test_schema_parity.py under
System & Deployment, plus a Recent Test Additions (2026-05-09)
entry with the full design rationale (full init_database() lifecycle,
PRAGMA-based type comparison, triggers in snapshot, out-of-scope notes).
- .github/workflows/schema-parity.yml: replace the invalid-hex
CREDENTIAL_ENCRYPTION_KEY placeholder ("test-ci-only-...") with a
valid 64-char zero-hex string. The key is never accessed on an empty
in-memory DB, but if a future code path ever calls encrypt(), the
workflow now produces a real test failure rather than a confusing
"Invalid encryption key format" crypto error.
Refs #713
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(schema-parity): inline maintainer note about required-status-check timing
Resolves /validate-pr suggestion: the warning about NOT adding this job
to branch-protection required-status-checks on day one was only in the
PR body. Move it inline as a header comment in the workflow YAML so it
stays visible to anyone reading the file directly (and survives long
after the PR is merged).
The risk is a future module move bypassing the path filter and
silently skipping the gate, then bricking unrelated PRs that don't
touch DB files. Leaving it optional + self-skipping is the safe default.
Refs #713
* ci(schema-parity): widen path filter to include src/backend/database.py
The parity test mirrors init_database()'s migrations->schema->migrations
lifecycle (database.py:139-164), but the path filter only watched
src/backend/db/**. A future re-ordering of init_database() itself would
slip past the gate. Add src/backend/database.py to both pull_request
and push paths so changes to the lifecycle trigger the check.
Refs #713
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(circuit-breaker): Redis-coordinated state with backoff + dormant (#631) (#698)
Pre-fix the circuit breaker lived in a per-process dict in agent_client.py.
Two uvicorn workers each tracked their own state, each probed dead agents
every 30s, and `monitoring_service` (which uses raw httpx and never
consulted the circuit at all) wrote 4 health_check rows per cycle for
the same dead agent. With a multi-hour outage that produced 400+ failures,
hundreds of concurrent SQLite writes from two workers, and the lock
contention cascaded into WebSocket auth → UI unresponsive for everyone.
Fix shape:
1. State moves into Redis (`agent:circuit:{name}` hash) with atomic Lua
scripts for record_failure / record_success / allow_request. Single
source of truth across workers; "Circuit OPENED" log fires exactly
once per cluster transition because the script returns prior+new
state and only one worker observes the closed→open boundary.
2. Exponential backoff on the open state — 30s → 60s → 120s → 240s,
capped at 300s. After 10 consecutive open-state probes without
recovery (~40min of attempts), the circuit transitions to **dormant**
and stops probing entirely.
3. Probe-lock (`SET NX EX 10` on `:probe-lock` key) ensures only one
worker fires the half-open probe per cooldown window.
4. `monitoring_service.check_network_health` now feeds its result into
the circuit (record_success on 2xx-4xx, record_failure on connect
errors / timeouts / 5xx) — previously its failures were invisible to
the circuit. `perform_health_check` short-circuits to a synthetic
AgentHealthDetail when the circuit is dormant: no httpx calls, no DB
writes. This is the cure for the SQLite-contention root cause.
5. Operator hooks: `force_circuit_dormant(agent, reason)` and
`reset_circuit(agent)`. The autonomy toggle calls them
(disable→dormant, enable→reset) — disabling autonomy now stops
probing for that agent (AC#5). The manual
POST /api/monitoring/agents/{name}/check resets first so the probe
actually runs (the documented dormant-recovery path).
Fail-open: when Redis is unreachable the breaker degrades to "always
allow request" — matches the rate-limiter pattern in webhooks.py and
keeps a Redis blip from breaking agents that are otherwise healthy.
Tests:
- tests/integration/test_circuit_breaker.py — 18 tests against real
Redis: state machine, backoff curve, dormant entry/exit, probe-lock
cross-worker semantics, transition-logged-once, operator hooks,
scan correctness, fail-open.
- tests/unit/test_monitoring_dormant_skip.py — 5 tests verifying the
perform_health_check bail-fast path: when dormant, no DB writes; when
not dormant, the full 4-row write path runs as before.
- tests/unit/test_circuit_breaker.py — kept the connection-pool and
exception-hierarchy tests; the state-machine tests moved to
integration since they need real Redis.
Live verified end-to-end: killed agent-server inside container, drove
through closed → open → dormant via repeated perform_health_check calls,
confirmed 0.5ms bail-fast in dormant state with zero new DB rows; toggled
autonomy off→on and observed dormant→closed transitions.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(#411): canary invariant harness Phase 1 (S-01, E-02, L-03) (#653)
* feat(#411): canary invariant harness Phase 1 (S-01, E-02, L-03)
Implements the canary harness from #411 that catches the bug class behind
PRs #378, #403, #129, #226 — race conditions and cross-component state
drift between Redis × SQLite × agent registries that unit tests miss.
Architecture (per design discussion in PR comments):
- Deterministic library (src/backend/canary/) — pure-function checks
shared between the watcher and the on-demand admin endpoint. No LLM
reasoning anywhere — same snapshot in, same violations out.
- Watcher service (services/canary_service.py) — 5-min APScheduler-style
loop modeled on cleanup_service.py. Disabled by default; enable on
staging/dev with CANARY_ENABLED=1.
- Fleet manifest (config/canary-fleet.yaml) — synthetic load via the
existing /api/systems/deploy mechanism. Without traffic the harness
reports trivially-green cycles.
Phase 1 invariants:
- S-01 (slot–row bijection) — Redis ZRANGE vs SQL running rows; drain
sentinels filtered. Critical severity.
- E-02 (no phantom reversal) — terminal executions stay terminal.
Phase 1 uses Redis-backed state comparison instead of Vector log diff
for simplicity. Critical severity.
- L-03 (delete cascades) — orphan-row scan across cross-cutting tables
(agent_sharing, agent_schedules, schedule_executions [non-terminal],
agent_skills, agent_tags, agent_shared_files, agent_public_links,
pending operator_queue / access_requests, agent-scoped mcp_api_keys,
active chat_sessions) plus orphan agent:slots:* Redis key scan.
Critical for active-orchestration orphans, major otherwise.
Persistence + API:
- canary_violations table (migration 34, schema in db/schema.py).
- GET /api/canary/violations + /violations/stats + /violations/{id} —
admin-only read paths over the persisted history.
- POST /api/canary/run-cycle — admin-only on-demand trigger; delegates
to the same CanaryService.run_cycle() the background loop calls.
Notifications:
- One green→red transition emits one notification via
db.create_notification(notification_type='alert', category='canary').
- Severity → priority: critical → urgent, major → high, minor → normal.
- Continuing-red cycles do not re-notify; the row in canary_violations
is the trend signal, the bell is the "now" signal.
Tests:
- 27 unit tests in tests/test_canary_invariants.py covering
CanaryOperations CRUD/filters/stats, snapshot collector behaviour,
each invariant's holds-and-fires cases, the L-03 critical/major
severity split, and the runner registry.
Smoke-test recipe (Option 1 — orphan SQL row, no code changes):
# 1. Set CANARY_ENABLED=1 and restart backend (creates canary_violations
# table via migration; starts the watcher).
# 2. Deploy the fleet:
curl -X POST -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "$(jq -Rs '{manifest: .}' < config/canary-fleet.yaml)" \
http://localhost:8000/api/systems/deploy
# 3. Ba…
7 tasks
vybe
added a commit
that referenced
this pull request
Jun 19, 2026
…#1149) Surface per-schedule performance on the Overview tab and the Schedules tab, both from a SINGLE compact aggregate (no N per-schedule round-trips) — extends #1107 (Overview) and generalises #868 (per-schedule deep analytics). Backend: - db `get_agent_schedules_summary(agent, hours)` — one rollup row per non-deleted schedule (zero-run schedules included): terminal success_rate (success / (success + failed[incl. error]); None when zero terminal), NULL-skipping avg_duration_ms, cost_total, context_avg, tool_call_total (parsed over newest 5,000 rows agent-wide, tool_calls_sampled flag), and last-run outcome. Cheap grouped SQL; iso_cutoff window (Invariant #16). - GET /api/agents/{name}/schedules/analytics-summary?window=7d|14d|30d (AuthorizedAgent). Declared BEFORE /{schedule_id} in routers/schedules.py so the literal segment isn't captured as a schedule_id (Invariant #4) — putting it in analytics.py would be shadowed (schedules_router mounts first). - models: ScheduleSummaryRow + AgentSchedulesSummaryResponse (Invariant #14). Frontend (single fetch, two consumers — Invariant #7): - executions.js fetchSchedulesSummary, cached per ${name}:${window} like fetchAgentAnalytics. - OverviewPanel: "Schedules performance" section, honors the existing 7/14/30d window selector, each row deep-links to the Schedules tab; hidden at zero. - SchedulesPanel: inline mini-stats per row (success rate, avg duration, runs, last-run dot) — badge style, no new chart/modal — from the same call. Tests: tests/unit/test_1115_schedules_summary.py (6) — terminal success rate, NULL-skip avg, tool-call total, zero-run-still-appears, soft-delete excluded, out-of-window excluded. Full analytics suites green (30 passed). Frontend prod build clean; endpoint verified live across 7/14/30d windows. Related to #1115 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Eugene Vyborov <1073874+vybe@users.noreply.github.com>
This was referenced Jun 24, 2026
This was referenced Jun 29, 2026
vybe
pushed a commit
that referenced
this pull request
Jul 6, 2026
…terprise#78) (#1476) * feat(enterprise): Client Portal "My Agents" roster page (trinity-enterprise#78) Add the first client-facing Client Portal surface to the OSS bundle, gated by the `client_portal` entitlement (backing module in trinity-enterprise#91): - `views/enterprise/ClientPortal.vue` — a card grid of the agents shared with the signed-in email (avatar or deterministic initials tile, owner, shared date; a disabled "Chat — soon" affordance signals the next slice). Loading / empty / error states; dark-mode; falls back to initials if an avatar URL fails to load. - Route `/enterprise/client-portal` gated `meta.requiresEntitlement: 'client_portal'` (302s to the enterprise catalogue when unentitled; 404 from the backend in OSS-only builds). - `stores/clientPortal.js` — domain store; `fetchRoster()` over the gated `/api/enterprise/client-portal/my-agents` endpoint (Invariant #7: API via store, not the view). - Enterprise catalogue card in `views/enterprise/Index.vue`. The Vue ships in the OSS bundle (no IP) but stays hidden until the enterprise module registers `client_portal` — safe to land independently of the submodule bump. Related to trinity-enterprise#78. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(enterprise): Client Portal chat with a rostered agent (trinity-enterprise#78) Make the roster cards actionable: a "Chat" button opens a slide-over chat drawer for that agent. Second Client Portal slice, on top of the "My Agents" roster. - `views/enterprise/PortalChat.vue` — slide-over chat panel (user/assistant bubbles, typing indicator, textarea composer, Enter-to-send). Assistant markdown rendered via `utils/markdown.js` (DOMPurify, Invariant H-005). - `ClientPortal.vue` — the card "Chat — soon" placeholder is now a live "Chat" button that opens the drawer for the selected agent. No new backend: chat reuses the existing OSS `POST /api/agents/{name}/chat` (via `agentsStore.sendChatMessage`), which already authorizes a user whose email is on the agent's `agent_sharing` allow-list — exactly the roster set. The portal remains the entitlement-gated surface; the chat capability itself is the OSS one a shared user already has. Verified live (PG): as the shared user, chatting a rostered agent through the portal's endpoint returns a real agent reply. SFCs compile clean. Related to trinity-enterprise#78. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(portal): verified-email portal session — OSS fence + public sign-in page (trinity-enterprise#78) The Client Portal's real client identity: a verified email, not a platform account. OSS owns the edition-agnostic primitive + the security fence; the enterprise module (trinity-enterprise#91) mints the token after code verification. OSS (`dependencies.py`) — mirrors the MFA-challenge-token pattern: - `create_portal_session_token(email)` / `decode_portal_session(token)` — a scope=`portal_session` JWT carrying only the email (no `sub`, no users row). - Fence: `get_current_user` and `decode_token` reject a `portal_session` token, so it authenticates NOTHING on the platform — only the entitled portal endpoints accept it (via the module's `get_portal_identity`). Verified live: 401 on `/api/agents` and `/api/settings`, 200 only on the portal roster. Frontend — public client surface: - `views/Portal.vue` at `/portal` (standalone, no NavBar, no `requiresAuth`): email → 6-digit code → roster of the agents shared with that email. A client signs in with no platform account. - `stores/clientPortal.js`: `requestCode` / `verifyCode` / `signOut`; the portal token is persisted (localStorage) and used as the auth header for the portal endpoints, falling back to platform login for operator preview. A 401 drops the token back to the sign-in form. Chat over a portal session is the next slice (the OSS chat endpoint fences the portal token by design); the roster card shows "Chat — soon" in the public view. Related to trinity-enterprise#78. Depends on trinity-enterprise#91 (mint + endpoints). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
vybe
added a commit
that referenced
this pull request
Jul 8, 2026
* fix(agent): make agent /tmp tmpfs size configurable via AGENT_TMP_SIZE (#1231) (#1233)
Agent containers mounted /tmp as a hardcoded 100 MB noexec,nosuid RAM-backed
tmpfs. It fills easily — e.g. `gh` CLI install artifacts (~38 MB) that hardcode
/tmp and bypass the #1098 TMPDIR redirect — after which every /tmp write fails
with "No space left on device", including git's commit scratch, so autonomous
scheduled runs "complete" but silently fail to persist. The size being a
literal meant operators couldn't tune it without a code change + base-image
rebuild.
- capabilities.py: AGENT_TMPFS_MOUNT size now read from AGENT_TMP_SIZE (env on
the backend service, which builds the agent mount spec), default 512m,
validated `^\d+[mg]$` with empty/invalid → default. noexec,nosuid stay
hardcoded — only size is configurable, and it stays bounded (counts against
the agent memory cgroup). Single source of truth, so create (crud.py) and
recreate (lifecycle.py) can't drift.
- Wire AGENT_TMP_SIZE=${AGENT_TMP_SIZE:-512m} on the backend service in both
docker-compose.yml and docker-compose.prod.yml; document in .env.example.
- architecture.md Container Security: note the now-configurable size.
- tests/unit/test_1231_agent_tmp_size.py: default, valid m/g, case-fold,
invalid→default, and the security flags are never dropped.
Mount specs are creation-time: existing agents pick up a new size on recreate,
not restart. Builds on #1098 (TMPDIR redirect) — closes the gap for tools that
hardcode /tmp. The agent-side git-sync silent-persist-failure is a separate
issue in the abilities repo, per the ticket.
Related to #1231
Co-authored-by: Eugene Vyborov <1073874+vybe@users.noreply.github.com>
* feat(ui): per-schedule performance scorecards on Agent Detail (#1115) (#1149)
Surface per-schedule performance on the Overview tab and the Schedules tab,
both from a SINGLE compact aggregate (no N per-schedule round-trips) — extends
#1107 (Overview) and generalises #868 (per-schedule deep analytics).
Backend:
- db `get_agent_schedules_summary(agent, hours)` — one rollup row per
non-deleted schedule (zero-run schedules included): terminal success_rate
(success / (success + failed[incl. error]); None when zero terminal),
NULL-skipping avg_duration_ms, cost_total, context_avg, tool_call_total
(parsed over newest 5,000 rows agent-wide, tool_calls_sampled flag), and
last-run outcome. Cheap grouped SQL; iso_cutoff window (Invariant #16).
- GET /api/agents/{name}/schedules/analytics-summary?window=7d|14d|30d
(AuthorizedAgent). Declared BEFORE /{schedule_id} in routers/schedules.py
so the literal segment isn't captured as a schedule_id (Invariant #4) —
putting it in analytics.py would be shadowed (schedules_router mounts first).
- models: ScheduleSummaryRow + AgentSchedulesSummaryResponse (Invariant #14).
Frontend (single fetch, two consumers — Invariant #7):
- executions.js fetchSchedulesSummary, cached per ${name}:${window} like
fetchAgentAnalytics.
- OverviewPanel: "Schedules performance" section, honors the existing 7/14/30d
window selector, each row deep-links to the Schedules tab; hidden at zero.
- SchedulesPanel: inline mini-stats per row (success rate, avg duration, runs,
last-run dot) — badge style, no new chart/modal — from the same call.
Tests: tests/unit/test_1115_schedules_summary.py (6) — terminal success rate,
NULL-skip avg, tool-call total, zero-run-still-appears, soft-delete excluded,
out-of-window excluded. Full analytics suites green (30 passed). Frontend
prod build clean; endpoint verified live across 7/14/30d windows.
Related to #1115
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Eugene Vyborov <1073874+vybe@users.noreply.github.com>
* feat(ui): in-app bug reporting from the floating Help widget (#1116) (#1283)
* docs(readme): document the Trinity Ops Agent and PostgreSQL backend/migration (#1290)
Adds a top-of-README callout recommending PostgreSQL for production (SQLite remains the zero-config dev default, opt-in via DATABASE_URL, #300), links the public Trinity Ops Agent (trinity-ops-public) for instance operations, and documents migrating existing SQLite instances via its /migrate-to-postgres skill. Also adds a Database section, a DATABASE_URL env row, and an ops-agent entry in the docs index.
Co-authored-by: Eugene Vyborov <eugene@beingluminous.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(reliability): unify the SUB-003 auth-class failure classifier into one shared module (#1088) (#1297)
The `is_auth_failure` + `AUTH_INDICATORS` + `NON_AUTH_KILL_MARKERS` (#904)
logic was duplicated inline in `subscription_auto_switch.py` and
`scheduler/service.py`, kept in sync by a hand-written "keep these lists in
sync" comment — exactly how the #904 kill-marker bug class re-appears.
Consolidate into one canonical module:
- New `src/backend/services/failure_classifier.py` — canonical, pure-stdlib
classifier (55 lines). `subscription_auto_switch.py` now re-exports
`is_auth_failure` unchanged, so existing importers
(`routers/chat.py`, `services/task_execution_service.py`) and their test
patch targets keep working.
- New `src/scheduler/failure_classifier.py` — byte-identical vendored mirror.
The scheduler runs in a separate container and cannot import
`backend.services`; it uses the classifier for log-labelling only (picks the
`logger.error` wording, never gates a switch). The agent-runtime classifier
in `error_classifier` is intentionally NOT merged — it diverges semantically
and stays kill-safe by `_classify_signal_exit` precedence (D4).
- Byte-identity is enforced by
`tests/unit/test_904_sigkill_no_false_auth.py::TestBackendSchedulerParity`;
the re-export is pinned by `TestBackendReExportGuard`. No hand-sync.
Pure structural refactor, no behavioral change (verified by SHA-256 equality
of the two copies and line-for-line comparison vs the deleted code). The test
rewrite also drops the prior `exec(compile(...))` source-slicing in favour of
`importlib` path-loading, removing the only injection primitive in scope.
Tests: 19/19 pass in `test_904_sigkill_no_false_auth.py`.
CSO --diff: CLEAR (docs/security-reports/cso-diff-2026-06-21.md).
Refs #1088
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(orchestration): pull-pilot routing for agent→agent MCP chat behind default-OFF flag (#946) (#1293)
* feat(orchestration): pull-pilot routing for agent→agent MCP chat behind default-OFF flag (#946)
Phase 2 PoC for pull/work-stealing (Epic #1045, umbrella #1081). When
MCP_AGENT_CHAT_PULL_ENABLED is ON, a sequential agent→agent (scope='agent',
non-self) chat_with_agent is routed by the MCP server through the durable async
/task path instead of the synchronous held /chat; the caller gets an immediate
{accepted|queued, execution_id} receipt and polls get_execution_result.
scope='user', self-tasks, and parallel=true are unchanged. Default OFF — flag
flip + MCP routing revert is the whole rollback.
- config.py: canonical MCP_AGENT_CHAT_PULL_ENABLED registry entry (both services
read the SAME env key, so a single-.env deploy can't drift).
- settings.py: surface mcp_agent_chat_pull_enabled in /api/settings/feature-flags
(auth-gated, observability-only — not a UI surface).
- chat.py: release the idempotency claim on the two /task dispatch-breaker-open
(CircuitOpen) deny paths, mirroring /chat and CapacityFull (T5 fix) — without
it a breaker-open reject silently blocks same-key retries for 24h.
- mcp-server: scope-based pull routing + D8 dispatch-mode idempotency token so a
flag flip can't replay a wrong-shape snapshot across endpoints; startup log of
the routing mode for the soak's control/treatment window.
- tests: chat.test.ts (routing fork + key behavior), test_946_task_idempotency_on_deny.py
(deny-path claim release), feature-flag exposure tests.
- docs: ACTOR_MODEL_POSTCARD (#945 resolved), PULL_PILOT_946_SOAK harness +
go/no-go record, CSO diff audit (CLEAR), TARGET_ARCHITECTURE/architecture updates.
Refs #946
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(feature-flows): document pull-pilot routing (#946) + AGENT_TMP_SIZE tmpfs (#1231)
Sync feature-flow docs with recent changes:
- agent-to-agent-collaboration.md: new Pull-Pilot Routing (#946) section —
flag-gated MCP routing fork to the durable async /task path, poll-for-result
receipt contract, D8 idempotency route token, feature-flag exposure, and the
T5 /task dispatch-breaker-open deny-path claim release.
- container-capabilities.md: refresh stale tmpfs facts — agent /tmp size is now
operator-configurable via AGENT_TMP_SIZE (default 512m, noexec,nosuid fixed),
plus the TMPDIR=/home/developer/.tmp heavy-scratch redirect (#1098).
- feature-flows.md: add Recent Updates index rows for #946 and #1231.
Refs #946
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Eugene Vyborov <eugene@beingluminous.com>
* feat(agent): runtime data_paths with portable export/import (#1169) (#1294)
* feat(agent): runtime data_paths with portable export/import (#1169)
Declare an agent's runtime data (SQLite DBs, datasets) under data/ on the
already-durable home volume — no separate volume, no platform schema change.
- template.yaml `data_paths:` surfaced by template_service (github + local)
and materialized at creation by crud.py -> git_service.materialize_data_paths:
writes ~/.trinity/data-paths.yaml and appends data/ + entries to the agent's
own .gitignore (idempotent). Opt-in; empty list is a no-op.
- S4 persistent-state and data_paths now share one extracted heredoc/list
primitive (materialize_trinity_yaml_list / _read_trinity_yaml_list).
- New routers/agent_data.py: POST /data/export (stream | base64, 413 over cap,
manifest-only tar when data/ missing) and POST /data/import (proxies to the
agent-server restore primitive; data/** allowlist + traversal guard;
Idempotency-Key). Both serialized per agent by a cross-worker Redis op lock.
- MCP tools export_agent_data / import_agent_data (Invariant #13).
- Validation checks DP-001..DP-005 in agent-validation-spec.
- Docs: architecture, requirements, feature-flows index + agent-data-volumes
flow, agent guide; CSO diff audit report (0 critical/high).
Tests: ~30 unit + TestClient tests (export/import endpoints, allowlist,
gitignore, template surface). PR2 (scheduled snapshots + pre-snapshot
quiesce hook + retention/cascade) deferred.
Closes #1169
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(agent-data): satisfy sys.modules pollution lint in #1169 tests
The two new data_paths test files copied the baselined `patch.dict` +
bare `del sys.modules[...]` loader from test_persistent_state_allowlist.py,
which the sys.modules pollution lint flags as NEW (non-baselined) violations.
Adopt the blessed snapshot/restore exception (precedent:
test_telegram_webhook_backfill.py): declare a top-level
`_STUBBED_MODULE_NAMES` list + an autouse `_restore_sys_modules` fixture, and
install the stubs / evict the cached module directly (the fixture owns
restoration). Removes the bare `del sys.modules[...]` entirely rather than
hiding it. Drop the now-unused `patch` import from the gitignore test.
Lint passes (no new violations); both files' 16 tests still green; no
cross-file leakage.
Refs #1169
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(feature-flows): sync recent changes — #1231 tmpfs, #1115/#1231 index rows
Fix container-capabilities.md for the now-configurable agent /tmp tmpfs
(#1231): default 100m → 512m via AGENT_TMP_SIZE, and correct the stale
full_capabilities ternary excerpts to the shared AGENT_TMPFS_MOUNT constant
(noexec,nosuid always applied, both modes). Add Recent Updates index rows for
the per-schedule performance scorecards (#1115) and the tmpfs-size fix (#1231);
the #1169 and #1116 rows already shipped in-commit.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Eugene Vyborov <eugene@beingluminous.com>
* fix(security): authenticate the in-container agent server on the shared agent network (#1159) (#1292)
* fix(security): authenticate the in-container agent server on the shared agent network (#1159)
The in-container agent server (:8000) had zero inbound auth on
trinity-agent-network: any agent could read a sibling's .env secrets or
run arbitrary Claude on it. Every backend->agent call now carries a
per-agent X-Trinity-Agent-Token = HMAC-SHA256(AGENT_AUTH_SECRET,
"trinity-agent-auth:v1:"+name), verified by a pure-ASGI middleware on all
HTTP and WebSocket routes (constant-time compare; only /health exempt).
- Derive-don't-store: the master AGENT_AUTH_SECRET lives only in the
backend env (auto-generated by start.sh like SECRET_KEY); each container
receives only its own token, so a compromised agent cannot compute a
sibling's. Fail-closed -- derive raises on an empty secret.
- Callers route through services/agent_auth.py (agent_httpx_client /
build_agent_auth_headers / merge_auth_headers); a static guard test
fails any new raw agent-{name}:8000 caller that bypasses them.
- Removed the dead, unauthenticated /ws/chat route (ran arbitrary Claude)
and the agent server's wildcard CORS (internal-only).
- Grace path for old images: empty TRINITY_AGENT_AUTH_TOKEN -> allow;
check_agent_auth_token_env_matches forces a one-pass recreate to inject.
- Retired the unused src/scheduler/agent_client.py.
Tests: unit (matcher, middleware, header guard, derivation) + security
isolation test. CSO diff audit in docs/security-reports/cso-diff-2026-06-20.md.
Closes #1159
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(feature-flows): document agent-server authentication (#1159) + sync recent flows
Add a feature-flow doc for the in-container agent-server inbound auth shipped
in this PR, and sync the index with two recent merged changes.
- New feature-flows/agent-server-authentication.md: end-to-end trace of the
derived X-Trinity-Agent-Token (HMAC over AGENT_AUTH_SECRET), the pure-ASGI
middleware enforcing it on every HTTP/WS route, fail-closed vs grace path,
recreate reconciliation, and the migrated callers + static guard. Added to
the Authentication & Security catalog in the index.
- container-capabilities.md: refresh the stale /tmp tmpfs size (was a hardcoded
100m) to the configurable AGENT_TMP_SIZE (default 512m, #1231).
- Recent Updates rows for #1159, #1231, and the previously-missing #1115.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Eugene Vyborov <eugene@beingluminous.com>
* docs(voip): genericize moved-issue reference in feature-flow
#1039 (configurable data-retention) moved to the private enterprise
tracker; replace the now-private issue number in voip-telephony.md with a
generic description so the public doc doesn't deep-link a private issue.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(agents): server-side compatibility validation with auto-fix (#668)
Run ~100 best-practice checks (11 categories) against a running agent's
workspace, surfaced non-blocking in the Overview tab with one-click
auto-fix for the 10 gitignore checks, plus an MCP tool.
- services/compatibility/ package (spec/collector/static_checks/ai_checks/fixes):
ONE docker exec -> in-container python -> JSON snapshot (secret files
existence-only, size/binary caps); pure STATIC checks (HARD-only) +
category-batched AI checks (Haiku, iterate-expected, fail-open, capped at
SOFT, secret-redacted); runtime-aware (claude-only checks skipped for
Codex/Gemini).
- GET/POST endpoints (read AuthorizedAgentByName; fix OwnedAgentByName, gitignore
only, per-agent Redis lock, atomic write, uncommitted until next sync;
include_ai path rate-limited). agent_compatibility_results table (dual-track
SQLite + Alembic) persists the latest snapshot; cascade/rename via AGENT_REFS.
- CompatibilityPanel.vue (two-phase fetch, grouped checklist, per-check fix,
re-run) in OverviewPanel; get_agent_compatibility_report MCP tool.
- 35 fixture-driven unit tests; spec sync-tested against docs/agent-validation-spec.md.
Persistence departs from the issue's "no DB table" note so AI verdicts show
without re-spend + enable fleet aggregation (see requirements section 41).
Fixes #668
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(compat): remove polynomial-ReDoS in secret-assignment regex (#668)
CodeQL py/polynomial-redos (high): `_ASSIGN_RE` captured the value as
`[ \t]*(.+?)[ \t]*$`. The lazy `.+?` and the surrounding `[ \t]*` can both
match a tab, giving polynomial backtracking when `_redact()` runs the pattern
over up to 48 KB of agent-supplied file text.
Capture the value greedily to end-of-line (`(.*)$`) and let the callers
strip — both `_looks_placeholder()` callers already `.strip()`, so secret
detection and redaction are behaviourally identical (verified: `=`, `:`,
`export`, and indented forms still match). 35 unit tests pass.
* feat(sso): OSS gated surface for enterprise SSO (OIDC) (#32)
Companion to trinity-enterprise#36. OSS carries only the entitlement-gated
surface; all SSO logic lives in the private submodule.
- Login.vue: "Sign in with <IdP>" buttons (shown only when the `sso` feature is
entitled and a provider is enabled), plus OIDC callback-fragment handling
(`/login#sso=ok|mfa|error`) — reuses the existing 2FA challenge UI when the
IdP login still requires a local second factor.
- stores/auth.js: completeSsoLogin() (reuses _finalizeLogin / _setMfaChallenge)
+ fetchSsoProviders() (empty in OSS-only builds — endpoint 404s).
- Settings.vue: admin-gated "SSO" tab → SsoPanel.vue (provider CRUD + test +
policy). Gated by enterpriseStore.isEntitled('sso'), same as the 2FA tab.
- Bump enterprise submodule to the SSO module commit.
- docs: architecture enterprise-modules row + requirements §40 (SSO/OIDC).
No new backend dependency (python-jose + httpx already in the image) and no
OSS Python changes — the mint/whitelist/mfa seams already exist.
Stacked on feat/5-2fa-totp (reuses the OSS mfa_gate + 2FA challenge surface).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(sso): bump enterprise submodule to OIDC hardening (#32 review)
Pulls in the email_verified / issuer-pinning / login-CSRF fixes
(trinity-enterprise 87c8f97). OSS gated surface unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(planning): note incubating goal-directed direction; reword voip flow
Add an "Incubating Directions (Not Yet Decided)" section to
TARGET_ARCHITECTURE.md capturing the goal-directed control-surface idea
(Objective + policies + roster + externally-measured evals), explicitly
bounded by CLAUDE.md §8 and sequenced after the pull migration + #300.
Incubating in trinity-enterprise#27.
Reword the voip-telephony flow note to drop a stale #1039 reference in
favor of describing the LOG_* data-retention no-op class directly.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(security): add CSO 2026-06-21 posture report
Routine /cso full-audit posture report (Phases 0–14, daily 8/10 gate).
Follows the docs/security-reports/ convention; no real secrets reproduced.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(user-docs): video library + per-page links, v0.6.1 What's New, sync dev features
- Add videos.md (35 published videos, newest-first by topic) and a README Watch section
- Add 'Watch' callouts to 32 feature pages linking the most relevant, newest videos
- Add user-facing whats-new/v0.6.1.md (translated from release notes; no issue numbers)
- Document dev-only features: agent runtimes (Claude Code/Codex/Gemini CLI, #1187),
agent data paths + export/import (#1169), compatibility validation (#668),
in-app bug reporting (#1116), subscription hot-reload (#1089), pull-pilot routing (#946),
configurable AGENT_TMP_SIZE (#1231), Postgres migration-runner groundwork (#1160)
- Index agent-runtimes, agent-data, and the previously-orphaned agent-session pages
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(setup): first-run operator intake + admin email login (abilityai/trinity-enterprise#38, #82)
Capture an optional operator email/company at first-run setup with an explicit,
unchecked-by-default opt-in to "occasionally receive important security & product
updates", submitted once to a new /v1/operator-intake endpoint on #1116's
Cloudflare intake app. The same email binds as the admin's sign-in identity so
the operator can log in with email + password — no verification email is sent (a
fresh install has no Resend key; the email is bound, not code-verified). The
code-based email second factor stays Phase 2 on the existing mfa_gate seam.
- backend: operator_intake_service (fire-and-forget, at-most-once via a
system_settings marker, DO_NOT_TRACK aware, owns installation_id); setup
endpoint captures profile + binds admin email; authenticate_user resolves the
admin by username OR registered email (password guard blocks code-only users);
PUT /api/users/me/email for the existing-admin transition
- frontend: SetupPassword email/company + consent checkbox; Login "username or
email" field; Settings -> General "Admin sign-in email" card
- config: OPERATOR_INTAKE_ENABLED / OPERATOR_INTAKE_URL (+ .env.example)
- docs: requirements section 43, architecture catalog, first-time-setup feature flow
- tests: 16 unit tests (intake idempotency/guards, email-login resolution, setup)
Fixes abilityai/trinity-enterprise#38
Fixes #82
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(feature-flows): index row for first-run intake + admin email login (trinity-enterprise#38, #82)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(setup): de-ambiguate email regex to clear CodeQL polynomial-ReDoS (#82)
The _EMAIL_RE pattern duplicated into setup.py and users.py had two
[^@\s]+ atoms around the literal \. that both also match '.', giving the
engine many ways to place the dot and backtracking polynomially on
user-controlled email input (CodeQL alerts #211, #212).
Constrain only the final segment to [^@\s.]+ (no dot) so the trailing \.
can align with exactly one position -> linear matching. Behaviour is
unchanged: multi-subdomain addresses still validate; an 80k-char
pathological input now resolves in ~2ms.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(credentials): curated credential file-type injection (SA keys, certs, SSH, binary) (#1305)
* feat(credentials): curated credential file-type injection — SA keys, certs, SSH, binary (enterprise#11)
Widens CRED-002 injection from the fixed 3-path exact allowlist
(.env/.credentials.enc/.mcp.json) to a curated set of credential file *types*,
without reopening the arbitrary-path RCE surface (#183/#590/#598).
- New services/credential_paths.py — single-source policy: ALLOW (.config/gcloud/**,
.kube/config, *.pem/*.key/*.crt/*.cert/*.p12/*.pfx, .ssh/id_*, + existing exact set)
with deny-precedence over anything executed/sourced at startup (shell rc,
CLAUDE.md/AGENTS.md/.claude/**, .mcp.json.template, .ssh/authorized_keys/config,
.git*, bin/**) and `..`/absolute traversal. Vendored byte-identically into the
agent image (Invariant #5) with a parity test.
- Agent-server hardening: the inject + update file loops now enforce the policy AND
a resolve-under-home traversal guard the original write path lacked; parent-dir
creation + chmod 0o600 preserved. New GET /api/credentials/list for export discovery.
- Binary-safe: inject carries files_b64 (base64); agent writes via write_bytes.
.credentials.enc gains a v2 {files, files_b64} envelope (legacy flat archives still
decrypt); encrypt/decrypt stay flat for the single-secret callers (SIEM/2FA/SSO).
- Export now captures the FULL injected set (via /list) + binary, not just the 2 defaults.
- Three surfaces in sync (Invariant #13): MCP inject_credentials gains files_b64;
frontend CredentialsPanel gains a file-upload affordance (text vs base64 auto-detected).
- Tests: allowlist test now exercises the REAL policy (newly-allowed + still-blocked),
+ credential_paths parity test + binary archive round-trip test. 61 pass.
- docs/memory/architecture.md: credential-path policy documented.
Related to Abilityai/trinity-enterprise#11. Loosens a deliberately-tight boundary —
run /cso on the diff before merge.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(credentials): close /cso findings on the injection widening (#11 review)
Security review of the widening surfaced one HIGH regression + hardening items;
all fixed here.
#1 (HIGH, RCE): the #598 .mcp.json content-validation guard was bypassable via
the new files_b64 (binary) channel — validate_mcp_config only checked `files`,
so `files_b64={".mcp.json": base64(<stdio-command MCP server>)}` skipped it and
configured an RCE MCP server on the target agent. Fix: .mcp.json may only arrive
as TEXT (files), where it is validated; rejected in files_b64 at the backend
inject router AND the agent-server write helper.
#2 (defense-in-depth): import/auto-import wrote decrypted archives via the
agent-server /inject layer only. Added validate_credential_set() (curated path
policy + .mcp.json content + no-binary-.mcp.json) on the backend import boundary
so enforcement is dual-layer as the issue mandates. (Archives are AES-GCM with
the server key, so a forged archive wasn't practical — but the layer belongs.)
#3: .ssh/ is now locked to id_* only — a stray *.key/*.pem under .ssh is no
longer accepted (policy was previously broader than the "SSH keys = id_*" intent).
#4 (noted): .config/gcloud/** can hold a google-auth executable credential_source;
only honored under non-default GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES=1.
Documented in credential_paths.py.
+6 regression tests (169 pass). CSO report: docs/security-reports/cso-2026-06-22-11-diff.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(credentials): exclude vendor dirs from cert globs + accurate export count (#11 live test)
Found while testing PR #1305 against a real local instance:
1. Over-capture: the broad *.pem/*.key/*.crt globs matched bundled CA files
(e.g. .local/.../site-packages/certifi/cacert.pem), so export's /list walk
swept vendored cert material into .credentials.enc. Added node_modules,
site-packages, .local, .venv/venv, .cache, go/pkg to the deny-list (both
root and nested forms) so cert globs only catch real credential files.
2. export's files_exported count re-read just the 2 default files (reported 1
while the archive actually held 5). export_to_agent now returns the true
captured count; dropped the redundant stale read.
Verified end-to-end on a live agent: allowed types inject (text+binary, 0600,
parent dirs), blocked paths 400 (incl. .ssh non-id_*, .mcp.json-via-files_b64,
weaponized .mcp.json text), and binary round-trips through export→import with
matching sha256. +5 regression tests (72 pass).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(security): annotate CSO 2026-06-21 findings as remediated; drop stale voip hunk
Two review-driven fixes ahead of the v0.7.0 cut:
- Annotate the CSO posture report (.md + .json) with post-audit remediation
status. The report audited `main` pre-cut and listed F1/F2/F3 as open
VERIFIED findings; they are already remediated on `dev` and ship in v0.7.0:
- F1 (unauth agent-server) -> #1159 X-Trinity-Agent-Token middleware
- F2 (fastmcp -> hono/undici) -> #1255, #1289; fastmcp ^4.3.0
- F3 (form-data CRLF via axios) -> #1254
- F4/F5/F8 exploit path closed by #1159 (auth gate)
Adds a top-of-report banner, per-row status tags, per-finding notes, and a
machine-readable `remediation_status` block in the JSON. Avoids publishing a
stale "open CRITICAL + exploit" to a PUBLIC repo without its fix context.
- Drop the voip-telephony.md reword: it is superseded by already-merged #1301,
which made the identical `#1039` -> `LOG_*` change on `dev`. Restoring to
merge-base removes the redundant/conflicting hunk from this PR.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore: set version to 0.7.0
* feat: streamline first-time setup wizard (abilityai/trinity-enterprise#49)
Drop the log-copied setup token, require an admin email, and rebuild the
first-run page as a welcoming animated welcome screen.
Backend (routers/setup.py, main.py):
- Remove the setup-token machinery entirely (ensure_setup_token /
clear_setup_token / Redis-shared token + the main.py startup emission).
Setup no longer depends on Redis — the admin write goes straight to SQLite.
- Make admin email REQUIRED (sign-in identity): missing -> 422 at the model
layer; blank/typo -> 400, validated before any write so setup never
half-completes. Password complexity (OWASP ASVS 2.1) still enforced.
- get_setup_status keeps setup_available:true for frontend back-compat.
Frontend (SetupPassword.vue):
- Full redesign: dark branded hero with an animated orbiting fleet
constellation (Trinity mark core + agent nodes on three rings), split
layout (stacks on mobile), prefers-reduced-motion aware.
- No setup-token field; email required; order email -> password (+confirm)
-> company -> updates opt-in. Removed the Redis-wait panel + polling.
Security tradeoff (chosen: accept + document): removing the token leaves the
unauthenticated first-run window with no proof-of-control. Documented as an
operator responsibility (deploy behind a tunnel/VPN until setup completes) in
docs/DEPLOYMENT.md Security Recommendations; endpoint still self-disables
after first success. See docs/security-reports/cso-diff-2026-06-23.md (F1).
Docs: DEPLOYMENT.md security note, architecture.md, requirements.md
(§15.2/§43), feature-flows/first-time-setup.md.
Tests: remove obsolete test_1165_setup_token_shared.py; update test_setup.py
(no token, email required) and test_setup_operator_profile.py (email
required, model-layer + blank/invalid rejection). 7 operator-profile unit
tests pass; new contract verified live (422/400 negative paths).
Fixes abilityai/trinity-enterprise#49
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(setup): full-bleed setup screen via normal flow, not position:fixed
The redesigned first-run page used `position:fixed; inset:0` for its root.
On wider viewports this left a band of the light `#app` (bg-gray-100)
background showing through on the right/bottom — a fixed root is clipped to
the nearest transformed/contained ancestor instead of the viewport, so its
coverage isn't guaranteed.
Switch the root to the original component's proven normal-flow approach
(`position:relative; width:100%; min-height:100vh`), which fills the
full-width `#app`, and make the decorative aurora/grid `position:absolute`
within it. Verified covering the full viewport at 2560x1440 (light mode, the
repro case) and stacking correctly at 430px.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(tests): defer routers.setup import so unit collection can't be corrupted
The CI backend-unit regression gate runs `cd tests && pytest unit/` (the whole
unit suite). test_setup_operator_profile.py imported `routers.setup` at module
(collection) time; that import — pulling in database/dependencies/services and
their many `utils.*` leaves — failed/perturbed sys.modules during collection and
INTERRUPTED the entire `unit/` collection (head collected ~2 of 2734 → the diff
gate flagged it as a new failure).
Defer the `import routers.setup` to a cached `_get_setup()` accessor used inside
the tests, so module collection imports only stdlib/pytest/fastapi/pydantic and
can never corrupt the suite. `_get_setup()` also spec-preloads the backend
`utils.*` leaves (helpers/errors/credential_sanitizer/password_validation/
url_validation/image_optimize) the same way conftest preloads `utils.helpers`,
without touching `sys.modules["utils"]`, so the import resolves cleanly at run
time regardless of harness utils state.
Verified with the exact CI command (`cd tests && pytest unit/ --co`): the full
suite now collects 2734 items with no interruption, and the 7 setup tests pass.
No conftest changes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(tests): drop sys.modules preload — plain lazy import (passes #762 lint)
The previous commit's `_get_setup()` spec-preloaded backend utils leaves via
`sys.modules[...] = …` / `.pop`, which tests/lint_sys_modules.py (#762) bans
outside conftest. It's also unnecessary: in the backend-unit gate
(`cd tests && pytest unit/`), tests/unit/conftest.py already installs
src/backend/utils as the canonical `utils` package, so a plain lazy
`import routers.setup` resolves the backend `utils.*` leaves natively.
Simplify `_get_setup()` to a cached plain lazy import — no sys.modules
mutation. Verified: lint clean (no new violations), `pytest unit/ --co`
collects 2734 with no interruption, and the 7 setup tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(tests): update #858 guards for setup-token removal (#49)
Removing the setup token (trinity-enterprise#49) deleted
`routers/setup.py::ensure_setup_token` and the lifespan token emission, so two
#858 regression guards asserted gone behavior and failed in the backend-unit
gate:
- test_ensure_setup_token_logs_token_via_logger_warning
- test_lifespan_emits_setup_token_via_logger_before_event_bus
The #858 invariant itself is intact: the lifespan still emits the first-run
notice via `logger.warning` (not print), after setup_logging() and before
event_bus.start(). Replace the token-specific guard with one that matches the
new FIRST-TIME SETUP warning by content + ordering, drop the now-obsolete
ensure_setup_token guard, and remove the unused BACKEND_SETUP constant. The
Dockerfile PYTHONUNBUFFERED parity checks and the no-print-in-lifespan guard are
unchanged. 4 passed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(whatsapp): deliver ChannelResponse.files as Twilio MediaUrl (#1315)
WhatsApp agents can now send files to users. send_response delivers
ChannelResponse.files as Twilio MediaUrl attachments (one message per file,
text first), reaching parity with the Slack adapter.
- New create_share_from_bytes() persists in-memory bytes through the FILES-001
pipeline (MIME-blocklist/quota/disk/DB) and mints a public ?sig= URL; both it
and create_share now share the extracted _persist_and_register helper.
- Per-agent file_sharing_enabled gate; 1h share TTL (cleanup reaper purges).
- Caps (image/audio/video ~5MB, documents ~16MB) on the detected MIME; graceful
text-link fallback when public_chat_url is unset/non-HTTPS, the MIME is
unsupported, or the file is oversized — never silently dropped.
- Per-file isolation: a rejected/failed file never aborts the text or siblings.
- 42 unit tests; requirements.md + whatsapp-integration.md updated.
Fixes #1315
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(whatsapp): webhook routes to backend, not frontend (#1281) (#1316)
The WhatsApp panel's deployment-prerequisite notice told operators to route
/api/whatsapp/webhook/* to the "frontend service". That path is a backend
FastAPI route (Twilio HMAC-verified); pointing tunnel ingress at the static
SPA silently drops inbound messages. Corrected to the backend service
(http://backend:8000), matching the cited PUBLIC_EXTERNAL_ACCESS_SETUP.md.
Related to #1281
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(ui): loading skeletons for Dashboard graph & timeline (#1266) (#1312)
The initial fleet/metrics load can take 20s+ on 10+ agent fleets (#1265);
until now the Dashboard rendered the "No agents" empty state (or blank
timeline) during that wait, so the UI looked frozen/broken.
- New reusable `SkeletonLoader.vue` (dark-mode aware, accessible
role=status/aria-busy, reserves space to avoid layout shift) with `rows`
(timeline/list) and `nodes` (collaboration graph) variants.
- `stores/network.js`: add `loading` (defaults true so the first paint is a
skeleton, not the empty state) + `loadError` (distinct failed-load state),
toggled in `fetchAgents` (finally-cleared so a failure never shows an
infinite skeleton).
- `Dashboard.vue`: graph canvas and timeline now render skeleton → error →
empty → content off those flags. Loading shows immediately on nav; error
states offer a Retry (reuses `refreshAll`).
Frontend-only; pairs with the backend perf work in #1265. `vite build` passes.
Related to #1266
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(db): set SQLite end-of-support to September 1, 2026 + Postgres migration notes (#1278) (#1314)
Records the firm SQLite end-of-support date and the SQLite → PostgreSQL
migration announcement/guidance. Documentation/decision only — SQLite code
removal stays with the migration work (#300/#1183/#746).
- docs/migrations/SQLITE_TO_POSTGRES.md (new): authoritative guide — EOL date,
what changes and when, switching a fresh deployment (DATABASE_URL + postgres
profile), migrating an existing deployment (backup-first; no turnkey data-copy
tool yet — honest cutover options), verification, and release-notes copy.
- docs/releases/v0.6.2.md (new, draft): EOL announcement section linking the
guide, seeding the next release notes.
- docs/planning/TARGET_ARCHITECTURE.md + docs/memory/architecture.md (Invariant #3):
reference the EOL date so it's discoverable outside the release.
- Cross-links the in-repo reminder companion (#1279).
Related to #1278
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs: stop disclosing enterprise functionality in public docs (trinity-enterprise#45) (#1311)
* docs: stop disclosing enterprise functionality in public docs (trinity-enterprise#45)
The public repo documented the full design, feature catalog, and gating
strategy of the paid enterprise tier — a free blueprint of what we monetize
and how it's built. This removes that competitive content and keeps only the
generic open-core seam public.
- Delete 4 strategy/design docs (OSS_ENTERPRISE_SPLIT_RESEARCH,
ENTERPRISE_ARCHITECTURE, feature-flows/enterprise-modules, ENTERPRISE_LOCAL_DEV)
- architecture.md "Enterprise Modules" table -> neutral seam pointer
(no paid-feature catalog, no enterprise_* table DDL, no per-module detail)
- requirements.md §35 -> abstract EntitlementService seam (drop the enumerated
module list + dead links to the deleted strategy docs)
- audit-trail.md: neutralize the lone enterprise-pillar mention
- CLAUDE.md: standing rule — enterprise designs live only in trinity-enterprise
- CI: enterprise-docs-guard.yml fails the build if live public docs reintroduce
paid-feature / private-schema tokens
Content is preserved (relocated to the private trinity-enterprise repo, see the
companion PR). Git-history scrub of the deleted files + point-in-time historical
docs (archive/, releases/, security-reports/) tracked as a follow-up.
Related to Abilityai/trinity-enterprise#45
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ci(enterprise-docs-guard): add least-privilege permissions block
Clears CodeQL actions/missing-workflow-permissions (medium). The guard only
checks out and greps, so contents: read is sufficient.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(releases): add 0.7.0 release notes
* fix(#1115): port get_agent_schedules_summary to SQLAlchemy Core (Postgres-safe)
The #300 SQLAlchemy migration dropped the get_db_connection import from
db/schedules.py but left get_agent_schedules_summary (#1115) calling it,
so the /schedules/analytics-summary endpoint raised NameError at runtime.
Surfaced for the first time by the v0.7.0 release-PR full-suite run (dev
pushes only lint).
Port the method to get_engine() Core queries like its siblings, and
replace the SQLite-only bare-column-with-MAX last-run query with a
portable ROW_NUMBER() window so it works on PostgreSQL too.
Also refresh the test_login_rate_limit_split config stub, which went
stale when auth.py grew a PUBLIC_ACCESS_REQUESTS_ENABLED dependency
(trinity-enterprise#10) — 8 collection errors under HEAD.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(voip): per-agent VoIP config panel + persisted voice (abilityai/trinity-enterprise#28)
Add the missing per-agent VoIP config UI (agent Settings/Sharing tab) and a
persisted per-agent Gemini voice. Shipped as plain OSS gated on the existing
voip_available platform flag — NOT entitlement-gated (a UI gate over a
money-spending OSS backend would be cosmetic; deliberate simplification of the
issue's original "entitlement-gated" framing).
Backend:
- agent_ownership.voice_name (default Kore) via dual-track migration
(SQLite db/migrations.py + Alembic 0004 + schema.py/tables.py). db
get/set_voice_name with read-path fallback to Kore for unset/invalid values.
- GET/PUT /api/agents/{name}/voice/name (PUT owner-only, validated against
GEMINI_VOICE_NAMES). _get_voice_name and voip_service now read the persisted
voice instead of the two hardcoded "Kore" sites.
- PUT /api/agents/{name}/voip/enabled toggle (owner-only, 404 when no binding);
create_binding upsert no longer forces enabled=1 so re-saving credentials
preserves a disabled state (call path already refuses disabled bindings).
Frontend:
- VoipChannelPanel.vue (modeled on WhatsAppChannelPanel) mounted in SharingPanel
under voip_available; shared src/constants/voices.js (drift-guarded vs backend);
AgentWorkspace picker defaults to the persisted voice; sessions store surfaces
voip_available.
Tests: tests/unit/test_28_voip_voice_config.py — voice fallback/roundtrip/
invalid->default, enable toggle + re-PUT-preserves-disabled (H3), and the
frontend/backend voice-list drift guard. Schema-parity + voip-db guards green.
Refs abilityai/trinity-enterprise#28
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(voip): HTTP-level endpoint tests for /voice/name + /voip/enabled (#28 review I1)
Pre-landing /review flagged that the new endpoints were covered only at the DB
layer. Add FastAPI TestClient tests (mount real routers, override auth deps, stub
db/voip_service) asserting:
- PUT /voice/name: owner-gated (403), 400 on unknown voice, empty clears to
default, valid voice persists; GET returns voice_name + available_voices.
- PUT /voip/enabled: owner-gated (403), 404 when no binding / when voip flag off,
200 reflecting state with no auth_token leaked.
Also capture a durable learning (docs/memory/learnings.md): the schema-parity
test is blind to db/tables.py drift — a missing Column there passes parity but
breaks at runtime; guard it with a db-accessor unit test that executes a live
select on the new column.
Refs abilityai/trinity-enterprise#28
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(docs): agent-readable zero-to-value onboarding (#1280) (#1336)
* refactor(docs): agent-readable zero-to-value onboarding (#1280)
Make the repo's entry points machine-first so an autonomous agent can self-orient and reach a useful result without a human translating context.
- AGENTS.md: add a "Using this file" machine-contract header (declares it the authoritative agent entry point, states the AGENTS/CLAUDE/README boundary, explains how to traverse). Rebuild Route-by-task with an explicit "Done when" zero-to-value signal per persona.
- CLAUDE.md: cross-link to AGENTS.md and frame CLAUDE.md as the contributor working agreement (auto-loaded by Claude Code), not the agent landing page.
- Fixes from a context-free agent onboarding test (AC#5 validation): AGENTS.md deploy verify no longer assumes an undefined $TOKEN (leads with `trinity agents list`, shows token derivation); deploy section states the running-instance prerequisite; README CLI example adds the `trinity agents list` verify step; docs/CLI.md leads with `pip install trinity-cli` (PyPI) and marks `-e src/cli/` as the from-source/dev variant.
Validated by an agent performing a zero-to-value deploy task using only repo files, no human context: self-oriented in 2 hops (README -> AGENTS.md) to a correct deploy+verify answer; friction items above are its findings, folded back in.
Repo-root AGENTS.md is hand-authored; the CLAUDE.md->AGENTS.md mirror (#1187) is per-agent-container (startup.sh), so these edits are conflict-free.
Related to #1280
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* docs(templates): machine-readable starter-template catalog (#1280)
Affordance sweep for agent self-selection. Previously an agent had to `ls`
config/agent-templates/ (24 dirs, 7 of them test fixtures) and open each
template.yaml to find a starting point.
- config/agent-templates/README.md: catalog grouping the 17 real templates
(single-purpose: scout/sage/scribe/demo-*/trinity-system; the dd-* due-
diligence suite) with one-line affordances, how-to-use, and an explicit
"not starting points" list for the test/canary fixtures
- AGENTS.md: link the catalog from the Deploy-an-agent section so the
zero-to-value path is "pick a ready-made template", not "author from scratch"
Related to #1280
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* fix(tests): un-quarantine and fix the 15 unmasked unit failures (#1103) (#1338)
Removes the @pytest.mark.skip quarantines added in #300 and fixes the
underlying issues. Each group fixed at root cause, not by matching assertions
to current behavior.
Environmental (git identity):
- tests/unit/conftest.py: set GIT_AUTHOR/COMMITTER_NAME/EMAIL process-wide so
in-test `git commit` works on a CI runner with no global git identity
("Author identity unknown"). Fixes test_reset_preserve_state_guardrails (3)
and the test_git_pull_branch end-to-end setups.
Test-setup bug (production code was correct):
- test_git_pull_branch.py: the repos did `git push -u origin main` but `git init`
defaults to `master` (no init.defaultBranch), so origin/main never existed and
_get_pull_branch correctly fell back to the working branch — the assertions
expecting "main" failed. Force `git init -b main` (local + bare). Fixes all 5
(TestGetPullBranch 2 + TestGitPullFromMainEndToEnd 3).
Test-isolation bug (assertions were correct):
- test_orphaned_execution_recovery.py: shared module-level mocks were reset with
plain reset_mock(), which keeps return_value/side_effect — so one test's
get_agent_container.side_effect bled into later tests under random ordering,
skewing recovery counts ("assert 3 == 2"). Reset with
reset_mock(return_value=True, side_effect=True). Stable across 5 seeds.
Real lint findings:
- docker/base-image/startup.sh: shellcheck now exits 0. Converted the 4 fragile
file-iteration loops to `find -print0 | while read` (SC2010/SC2045/SC2044),
hardened 8 `cd` with `|| exit 1` (SC2164), split the SC2155 export, and
documented-disabled SC2001 on the two regex `sed` lines that ${//} can't
express. `bash -n` clean. Un-skips test_startup_sh_shellcheck_clean.
backlog (3) and 929 (1) were already un-quarantined on dev (db_harness schema),
so no change needed there.
Verified: the 11 un-skipped tests pass across multiple random seeds; full unit
suite shows no regressions from these changes (the unrelated pre-existing
test_1115_schedules_summary / test_admin_email_login failures fail on dev too).
Related to #1103
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* chore(deps-dev): bump happy-dom (#1326)
Bumps the patch-and-minor group in /tests/git-sync with 1 update: [happy-dom](https://github.com/capricorn86/happy-dom).
Updates `happy-dom` from 20.10.5 to 20.10.6
- [Release notes](https://github.com/capricorn86/happy-dom/releases)
- [Commits](https://github.com/capricorn86/happy-dom/compare/v20.10.5...v20.10.6)
---
updated-dependencies:
- dependency-name: happy-dom
dependency-version: 20.10.6
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: patch-and-minor
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* chore(deps-dev): bump @types/node in /src/mcp-server (#1327)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 25.9.3 to 26.0.0.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)
---
updated-dependencies:
- dependency-name: "@types/node"
dependency-version: 26.0.0
dependency-type: direct:development
update-type: version-update:semver-major
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* chore(deps): bump fastmcp (#1325)
Bumps the patch-and-minor group in /src/mcp-server with 1 update: [fastmcp](https://github.com/punkpeye/fastmcp).
Updates `fastmcp` from 4.3.0 to 4.3.2
- [Release notes](https://github.com/punkpeye/fastmcp/releases)
- [Commits](https://github.com/punkpeye/fastmcp/compare/v4.3.0...v4.3.2)
---
updated-dependencies:
- dependency-name: fastmcp
dependency-version: 4.3.2
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: patch-and-minor
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* chore(deps): bump the patch-and-minor group (#1329)
Bumps the patch-and-minor group in /src/frontend with 5 updates:
| Package | From | To |
| --- | --- | --- |
| [axios](https://github.com/axios/axios) | `1.18.0` | `1.18.1` |
| [@playwright/test](https://github.com/microsoft/playwright) | `1.61.0` | `1.61.1` |
| [autoprefixer](https://github.com/postcss/autoprefixer) | `10.5.0` | `10.5.1` |
| [@rollup/rollup-darwin-arm64](https://github.com/rollup/rollup) | `4.62.0` | `4.62.2` |
| [@rollup/rollup-linux-arm64-musl](https://github.com/rollup/rollup) | `4.62.0` | `4.62.2` |
Updates `axios` from 1.18.0 to 1.18.1
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.18.0...v1.18.1)
Updates `@playwright/test` from 1.61.0 to 1.61.1
- [Release notes](https://github.com/microsoft/playwright/releases)
- [Commits](https://github.com/microsoft/playwright/compare/v1.61.0...v1.61.1)
Updates `autoprefixer` from 10.5.0 to 10.5.1
- [Release notes](https://github.com/postcss/autoprefixer/releases)
- [Changelog](https://github.com/postcss/autoprefixer/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/autoprefixer/compare/10.5.0...10.5.1)
Updates `@rollup/rollup-darwin-arm64` from 4.62.0 to 4.62.2
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.62.0...v4.62.2)
Updates `@rollup/rollup-linux-arm64-musl` from 4.62.0 to 4.62.2
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.62.0...v4.62.2)
---
updated-dependencies:
- dependency-name: axios
dependency-version: 1.18.1
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: patch-and-minor
- dependency-name: "@playwright/test"
dependency-version: 1.61.1
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: patch-and-minor
- dependency-name: autoprefixer
dependency-version: 10.5.1
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: patch-and-minor
- dependency-name: "@rollup/rollup-darwin-arm64"
dependency-version: 4.62.2
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: patch-and-minor
- dependency-name: "@rollup/rollup-linux-arm64-musl"
dependency-version: 4.62.2
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: patch-and-minor
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* feat(executions): propagate cancelled terminal status end-to-end (#679) (#1333)
Defense-in-depth follow-up to #671. Make the agent task-runner aware that an
operator cancel happened and surface a third terminal outcome — `cancelled` —
alongside success/failed, so a cancel is never recorded as a billable success
or an agent failure.
Agent server:
- ProcessRegistry records a `_terminated[execution_id]` marker on a successful
SIGINT send; `was_terminated()` (read-only, 300s lazy TTL, cleared on
register) lets the sync chat handler and async result callback relabel a
graceful-exit-0 / SIGKILL->504 turn as cancelled.
- `record_task_finish` accepts a neutral finish (success=None): a cancel
neither resets nor increments the dispatch-breaker failure counter (#526).
Backend:
- 3-way status map (success->SUCCESS, cancelled->CANCELLED, else->FAILED) in
the async callback (routers/agents.py) and the sync applier
(task_execution_service). An auth/rate terminal is never reclassified as a
cancellation — guarded at the backend trust boundary too (CSO finding 2).
- Consumers (message_router, chat, paid, public, validation_service) treat
cancelled as non-delivery; paid no longer settles on cancel (money bug).
- terminate writes CANCELLED only when it actually stopped a running turn; on
already-finished the agent's real terminal stands (Issue 7).
Tests: 9 new unit suites (64 cases) + execution-termination integration
additions; 85 unit tests pass locally. CSO diff audit: CLEAR.
Refs #679
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(ui): unify Chat + Session into one Chat tab with a session-mode toggle (#1112) (#1340)
Collapse the redundant Chat + Session tabs on Agent Detail into a single "Chat"
tab carrying a "Session mode" toggle (default ON), keeping the legacy stateless
surface as a first-class user-selectable mode rather than dead code.
- AgentDetail.vue: single `{ id: 'chat' }` tab (drop the separate Session entry).
New `chatMode` ref ('session'|'legacy', default 'session') persisted per-user in
localStorage['trinity.chatMode']. `sessionAvailable` = feature flag on AND
runtime has --resume (not Codex); `effectiveChatMode` forces legacy when the
Session surface is unavailable and hides the toggle. The toggle swaps
SessionPanel ↔ ChatPanel in-place (v-if). isFullscreenTab keys on the single
'chat' id; `?tab=session` aliases to 'chat' (hinting session mode).
- Execution-resume: ExecutionDetail "continue as chat" (?tab=chat&resumeSessionId)
forces legacy ChatPanel (which owns resume) via a transient, non-persisted
routeForcedMode — without rewriting the user's saved preference.
- No backend change (session_tab_enabled already exists). MobileAdmin unaffected:
its openChat is a self-contained mobile chat overlay, not an AgentDetail
deep-link, so there is nothing to repoint.
- docs: architecture Session Tab block + requirements §5.8 note.
Related to #1112
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* feat(access): Access tab — manage Trinity operators per agent (trinity-enterprise#17) (#1317)
New Access tab on Agent Detail that manages Trinity operators (platform users)
with access to an agent, distinct from the Sharing tab's external channel
clients. Draws the operator-vs-client line on the read path.
Backend:
- db.get_agent_operator_access(): outer-joins agent_sharing × users on the
grantee email (lower-cased, engine-based → PG+SQLite). Resolved → active
operator (username/role/last_active); unresolved → pending invite.
- GET /api/agents/{name}/access (AgentOperatorAccess model). Add/remove reuse
the existing /share + /share/{email}.
Frontend:
- AccessPanel.vue: operator roster (status + role badges, last-active), add by
email, remove. Access tab wired into AgentDetail (owner-gated).
- SharingPanel.vue: Team Sharing allow-list removed (moves to Access); dead
share-management script + stale "Team Sharing below" copy cleaned/repointed.
- stores/agents.js: getAgentAccess().
Tests: active-vs-pending classification + agent scoping. vite build passes.
Note: the strict client-vs-operator split (non-user emails → a dedicated client
roster) is deferred to the Sharing-side redesign (#18/#20); removing them here
now would orphan those grants, so all allow-list entries stay visible on Access.
Related to Abilityai/trinity-enterprise#17
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(ui): slim the Overview executions-by-type bars
The "Executions by type" stacked bars rendered full-width with a 1px
gap, so a busy agent's week read as a solid wall of color. Cap each
bar at 56px and center it inside its (still full-width) hover column,
and soften the top corner. The column stays flex-1 so spacing/tooltips
are unchanged and wider windows (14d/30d) thin naturally.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(voip): add Gacrux to the Gemini Live voice picker
Adds the "Gacrux — Mature" prebuilt voice to the per-agent VoIP voice
selector (and the shared AgentWorkspace per-session picker, which reads
the same list). Updated in lockstep across the three mirrored sources so
the frontend↔backend parity test stays green:
- src/frontend/src/constants/voices.js — single frontend source of truth
- src/backend/config.py GEMINI_VOICE_NAMES — write-validation allowlist +
read-path fallback
- tests/unit/test_28_voip_voice_config.py — hardcoded parity tuple
Follow-up to #1323 (per-agent VoIP config panel + persisted voice).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(ui): reframe Sharing tab to external-client sharing via channels (#1347)
Part of the Access/Sharing redesign (Epic trinity-enterprise#16). The Access
tab (#17) already owns Trinity operators; this scopes the Sharing tab to the
operator → external-client surface.
- Google-Docs-style "Share this agent" framing; operator language removed
(operators live on the Access tab).
- External access policy collapsed into one **Restricted ↔ Open** segmented
control over require_email/open_access (Restricted = approval-gated, Open =
anyone verified; identity proof always on for external sharing).
- Pending requests kept, reframed as external clients awaiting approval.
- Channels rendered as compact collapsible summary rows (new
ChannelDisclosure.vue). Detailed config stays reachable inside the expanded
row as a non-regressing interim seam — #19 replaces the body with a modal
dialog. No channel functionality removed.
- Outbound file sharing + public links nudged into a separate "Distribution"
section (distribution, not client access).
Frontend-only; no API changes. SharingPanel prop/emit contract unchanged.
Verified: both SFCs compile (vue/compiler-sfc), design-token check passes.
Related to abilityai/trinity-enterprise#18
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(architecture): trim architecture.md under the 150k-char context limit (#1344)
architecture.md had grown to ~156.7k chars (on dev), past the 150k
soft limit Claude Code warns about when auto-loading it each session
(it's `@`-imported by CLAUDE.md). Over the limit the file risks silent
truncation and eats a large slice of the context window every session.
Compressed the densest Cross-Cutting Subsystem narratives, the migration
and non-root-container invariants (#3/#17), a few catalog/endpoint rows,
and the longest frontend UI prose — preferring summary + pointer where a
dedicated `feature-flows/` doc already owns the deep detail (the doc's own
editorial rule). Result: 156.7k → 149.3k chars (~4.8% smaller).
No facts dropped: every issue tag, field name, default, and error-string
is preserved; protected SQLite DDL (tracked by /validate-schema) untouched;
heading/code-fence/table counts unchanged; all 12 added flow-doc links
resolve.
Related to the over-limit warning surfaced in Claude Code.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(deps): bump js-yaml from 4.2.0 to 5.1.0 in /src/frontend (#1330)
Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.2.0 to 5.1.0.
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.2.0...5.1.0)
---
updated-dependencies:
- dependency-name: js-yaml
dependency-version: 5.1.0
dependency-type: direct:production
update-type: version-update:semver-major
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* fix(ci): guard the Alembic (Postgres) track in schema-parity (#1342) (#1345)
* fix(ci): guard the Alembic (Postgres) track in schema-parity (#1342)
The schema-parity required check validated only the SQLite track
(migrations.py ↔ schema.py). A schema change that ships the SQLite
migration but omits the Alembic revision under
src/backend/migrations/versions/ passed every required check green yet
broke PostgreSQL — init_database() runs alembic_runner.upgrade_to_head(),
which applies revision files only and does not autogenerate from
tables.py. Two PRs reached "green CI but PG-broken" and had to be held by
hand.
Add a cross-track guard, folded into the existing required schema-parity
job (no new required-check to manage):
- scripts/ci/check_alembic_parity.py — fails a PR that ADDS schema DDL to
db/{migrations,schema,tables}.py without a net-new revision file under
src/backend/migrations/versions/. Pure stdlib, PR-only (diffs base...head).
- Heuristic / false-positive guard: the signal is a DDL keyword on an
*added, non-comment* line (SQL: CREATE/ALTER/ADD COLUMN/…; SQLAlchemy:
Column(/Table(/Index(/…). Comment edits, data-only and down migrations
carry no DDL keyword, so they don't trip it. Documented in the script
docstring and the workflow header.
- tests/unit/test_alembic_parity_guard.py — 20 tests incl. the acceptance
fixtures (SQLite-only change fails; dual-tracked passes; comment/data-only
pass). Wired into the parity pytest run.
Also notes the enforcement in architecture.md Invariant #3.
Verified locally: 24 tests pass; end-to-end smoke across clean / SQLite-only
(exit 1) / paired-revision (exit 0) scenarios behaves correctly.
Related to #1342
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(ci): tighten Alembic guard to require a new MIGRATIONS entry (#1342)
Verifying against real repo history surfaced a false positive: the
migration-runner refactor #1263 (_atomic_rebuild table rebuilds) re-emits
CREATE TABLE / CREATE INDEX for *existing* tables in a rename-swap but adds
no actual schema and no new MIGRATIONS entry — yet the original "any added
DDL keyword" heuristic flagged it, violating AC #4 (non-schema edits must
not trip).
Tighten the signal to two conjuncts: a schema change must (1) register a
net-new ("name", _migrate_fn) entry in the MIGRATIONS list AND (2) carry a
DDL keyword. Runner refactors / table rebuilds add no entry → exempt;
data-only new migrations carry no DDL → exempt; real column/table adds do
both → caught.
Validated against real commits:
• #740 agent_loops, #526 agent_ownership column → FAIL (correctly blocked)
• #668 compat, voice_name (both shipped an Alembic revision) → PASS
• #1263 runner refactor → PASS (false positive fixed)
A full post-Alembic history scan finds 0 outstanding missing revisions, so
no backfill is owed; the pre-Alembic columns are already in 0001_baseline.
28 tests pass (added MIGRATIONS-entry detection + the #1263 rebuild case).
Related to #1342
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Eugene Vyborov <eugene@beingluminous.com>
* docs(dependab…
This was referenced Jul 16, 2026
7 tasks
vybe
pushed a commit
that referenced
this pull request
Jul 17, 2026
…#1676) * feat(agents): editable display label with an immutable slug (ent#181) An agent had exactly one name — its slug — so "rename" meant the heavyweight identity change: `PUT /rename` stops the container, rewrites ~20 tables, clears every per-agent Redis keyspace, renames avatar files, and STILL leaves the agent's volumes under the old base, because Docker can rename neither a volume nor its immutable `trinity.agent-name` label. That path is the root of #1664/#1665/#1667/#1669/#1671. Yet "call it Marketing Bot" is the common case, and it should not touch any of that. Adds a display label that is rendered, never resolved. The slug stays the identity everything machine-facing keys on; the label is presentation. - `agent_ownership.display_label TEXT`, nullable — NULL means "render the slug", so no backfill and every existing agent is unchanged until someone sets one; clearing reverts to the slug rather than blanking a name. Dual-track migration (Invariant #3): SQLite `agent_ownership_display_label` + Alembic 0025, plus schema.py / tables.py. - `DisplayLabelMixin` (Invariant #2 — new setting, new mixin), with a batched reader for the fleet list so the hottest endpoint stays one query, and the four facade pass-throughs (the #1666 lesson: mocked tests can't see a facade gap). - `GET`/`PUT /api/agents/{name}/label`, owner-only. Read never coerces `label` to the slug — the UI must tell "no label" from "label equals slug". WS `agent_label_changed` broadcast. - Frontend: one resolution helper (`utils/agentName.js`) — no per-site `label || name`, which would show one agent under two names (§1.3.1 FR-3). The header pencil edits the label and shows the slug as secondary text (FR-4); list/tile surfaces render the label with the slug in the tooltip. The store goes through the shared axios client (Invariant #7). - The slug rename is demoted, not removed (FR-5): a separate "Rename the id instead…" affordance with copy stating what it does (restart, re-key, volumes stay under the old id) — owners who need it keep it; it stops being the default gesture. Requirements §1.3.1 written before the code (rule #1). Verified end-to-end on the live stack: label set → slug untouched (container intact, `/api/agents/{slug}` still 200, nothing restarted), blank and null both clear back to the slug. 28 tests. Decision (maintainer): OSS-core; demote the slug rename; label everywhere. Closes trinity-enterprise#181 * fix(agents): carry the label on the detail endpoint + make the WS broadcast live (ent#181) Self-review (/review) caught two consistency gaps I'd introduced. 1. `GET /api/agents/{name}` — the endpoint AgentHeader loads on page open — enriched the agent dict with owner/is_owner/can_share but NOT display_label. So on a fresh load or refresh the header showed the SLUG; the label only appeared after an edit round-tripped through the store. Reproduced live (detail returned display_label=None for a labelled agent), fixed by adding the one read, re-verified live. §1.3.1 FR-3 — the surfaces must agree. 2. The `agent_label_changed` broadcast was dead: it set only `type`, but the frontend WS client switches on `event` (both are the convention, per agent_started et al.), and no handler existed — so a label change on one client never reached others. Added `event` + the `data` shape the other agent_* events use, and a handler that updates the cached agent (the slug never moves, so it's a pure re-render). Endpoint test updated to assert the new broadcast shape.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This PR addresses two security/housekeeping issues discovered during a security scan analysis:
1. Remove Token Logging from MCP Client (Security Fix)
Problem: The MCP client (
src/mcp-server/src/client.ts) was logging the first 20 characters of JWT tokens and token length on every API request:This could expose sensitive information in production logs (CloudWatch, Datadog, etc.) and potentially aid attackers in token analysis.
Solution:
DEBUG_MCP_CLIENT=trueorNODE_ENV=development)Auth: present/missing)2. Add pytest HTML Reports to .gitignore (Housekeeping)
Problem: Auto-generated pytest HTML test reports (
tests/reports/*.html) were not in.gitignore, potentially leading to accidental commits.Solution: Added
tests/reports/*.htmlto the test artifacts section of.gitignore.Test Plan
DEBUG_MCP_CLIENT=true npm start- debug logs should workNODE_ENV=production npm start- tokens should NOT be loggedImpact