Fix git pushing bug - #9
Merged
Merged
Conversation
This was referenced Apr 12, 2026
dolho
added a commit
that referenced
this pull request
Jun 15, 2026
Add CLAUDE.md Rule #9: every schema change needs both a SQLite entry in db/migrations.py and a Postgres Alembic revision under migrations/versions/, plus DDL in schema.py/tables.py. SQLite stays supported; PG-only is eventual, not near-term. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
vybe
pushed a commit
that referenced
this pull request
Jun 15, 2026
…1186) * feat(db): adopt Alembic for PostgreSQL migrations — Phase 1 (#1183) The PostgreSQL backend's schema is now owned by Alembic instead of the fresh-build-from-schema.py path. SQLite keeps its bespoke db/migrations.py runner — the two coexist during the Postgres transition (SQLite is being retired). - migrations/ (env.py targets db/tables.py MetaData), alembic.ini, and a 0001_baseline revision that reuses the exact init_schema_postgres head DDL (tables + indexes + triggers) so a fresh PG DB built by `alembic upgrade head` is identical to the old fresh build. - db/alembic_runner.upgrade_to_head(): fresh DB -> upgrade head; pre-Alembic PG DB (no alembic_version) -> stamp baseline then upgrade (not rebuilt); managed DB -> apply pending revisions. - init_database() non-SQLite branch calls the runner (was init_schema_postgres). - alembic==1.18.4 in the backend image (scheduler doesn't run migrations). - Postgres-gated integration tests (TEST_POSTGRES_URL): fresh build, idempotency, downgrade-to-base, pre-Alembic stamp path, and schema parity vs the legacy init_schema_postgres build. All 5 pass against postgres:16. Verified end-to-end against a throwaway postgres:16. SQLite init path unchanged. Deferred: enterprise Alembic domain ships in the private enterprise repo (Phase 2); autogenerate-from-metadata + SQLite retirement (Phase 3, with #746). Related to #1183. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: require dual SQLite+Postgres migrations for schema changes (#1183) Add CLAUDE.md Rule #9: every schema change needs both a SQLite entry in db/migrations.py and a Postgres Alembic revision under migrations/versions/, plus DDL in schema.py/tables.py. SQLite stays supported; PG-only is eventual, not near-term. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(docker): ship migrations/ + alembic.ini in the backend image (#1183) db/alembic_runner.py sets Alembic's script_location to /app/migrations at runtime, but the Dockerfile only globbed top-level *.py plus named subdirs — the new migrations/ dir and alembic.ini were dropped from the prod image. A PostgreSQL deploy would crash-loop in init_database() -> upgrade_to_head() because the script directory is absent (same #1033 packaging-gap class; CI missed it since schema-parity/integration tests run against source and the prod smoke boots SQLite). COPY both into the image and include migrations/ in the readability chmod step. alembic.ini isn't needed at runtime (the runner builds its Config programmatically) but is copied so the alembic CLI works in-container for ops. 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>
7 tasks
1 task
dolho
added a commit
that referenced
this pull request
Jun 19, 2026
…ed helper Applies the six code-review findings on the #1265 dashboard perf PR: 1. [high] Add Alembic revision 0002_activities_created_index so EXISTING Postgres deployments (stamped at 0001_baseline, never re-running baseline) pick up idx_activities_created. Fresh PG builds already get it via 0001_baseline iterating db/schema.py:INDEXES. CLAUDE.md Rule #9 (dual-track). 2. [med] get_latest_execution_per_schedule now projects only the 6 dashboard fields instead of select(*) — no longer streams response/execution_log (100KB+)/tool_calls/message it never uses. Returns slim dicts (timestamps ISO-normalised to match the prior .isoformat() output); ops.py updated. 3. [med] Document that the admin timeline's unfiltered scope (full audit history incl. deleted agents) is intentional, not an accidental widening. 4. [low] Cap the context-stats HTTP connect timeout at 0.5s so a stale-`running` agent (container gone) fails fast instead of eating the full 2s read timeout — without reintroducing the per-agent Docker lookup that was the bottleneck. 5. [low] Extract the duplicated ROW_NUMBER window into a shared db/query_helpers.py:latest_per_group, reused by both bulk methods. 6. [low] get_latest_activity_for_agents maps rows by NAME via _mapping_to_activity (the helper drops the rn column), removing the fragile positional slice that would silently misalign if _ACTIVITY_COLUMNS were reordered. Tests: test_1265_dashboard_perf updated for the slim dict shape (+ asserts the narrow projection); 9 pass. 93 pass across affected suites. Alembic graph validated: single head 0002 -> 0001 -> base. Related to #1265 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
vybe
pushed a commit
that referenced
this pull request
Jun 19, 2026
…#1269) * fix(perf): kill Dashboard/timeline N+1 fan-out at fleet scale (#1265) The Dashboard + timeline took 20s+ to paint metrics on a 10+ agent fleet. Root cause was not the listed endpoints but blocking work serializing the event loop and several per-agent/per-schedule N+1 query patterns: - context-stats: `_fetch_single_agent_context` was `async` but ran a blocking Docker container lookup + a blocking per-agent DB query on the event-loop thread, so the `asyncio.gather` fan-out was effectively serial (10 agents x ~1-2s = the 20s). Now each coroutine does only the async HTTP call: the container host is derived as `agent-{name}` (Docker DNS) with no Docker call, and the latest activity is fetched once via a new bulk `get_latest_activity_for_agents` window query. - /api/agents/slots: replaced the per-agent Redis ZCARD loop with one pipelined round-trip (mirrors heartbeat_status_bulk). - /api/ops/schedules: replaced the per-schedule `get_schedule_executions` loop with one `get_latest_execution_per_schedule` window query. - /api/activities/timeline: pushed the per-user access filter into SQL (agent_name IN ...) and dropped the limit*2 Python-side over-fetch; added a standalone idx_activities_created index (schema.py + migration) so the fleet-wide ORDER BY created_at DESC stops scan+sorting. - Invariant #16: get_agent_health_history now uses iso_cutoff() instead of datetime.utcnow(); same in context-stats activity cutoff. No new cache layer, so no freshness/invalidation risk. New bulk methods are covered by tests/unit/test_1265_dashboard_perf.py (SQLite + Postgres via the #300 harness); window-function constructs verified on SQLite. Related to #1265 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(perf): address #1269 review — PG migration, slim projection, shared helper Applies the six code-review findings on the #1265 dashboard perf PR: 1. [high] Add Alembic revision 0002_activities_created_index so EXISTING Postgres deployments (stamped at 0001_baseline, never re-running baseline) pick up idx_activities_created. Fresh PG builds already get it via 0001_baseline iterating db/schema.py:INDEXES. CLAUDE.md Rule #9 (dual-track). 2. [med] get_latest_execution_per_schedule now projects only the 6 dashboard fields instead of select(*) — no longer streams response/execution_log (100KB+)/tool_calls/message it never uses. Returns slim dicts (timestamps ISO-normalised to match the prior .isoformat() output); ops.py updated. 3. [med] Document that the admin timeline's unfiltered scope (full audit history incl. deleted agents) is intentional, not an accidental widening. 4. [low] Cap the context-stats HTTP connect timeout at 0.5s so a stale-`running` agent (container gone) fails fast instead of eating the full 2s read timeout — without reintroducing the per-agent Docker lookup that was the bottleneck. 5. [low] Extract the duplicated ROW_NUMBER window into a shared db/query_helpers.py:latest_per_group, reused by both bulk methods. 6. [low] get_latest_activity_for_agents maps rows by NAME via _mapping_to_activity (the helper drops the rn column), removing the fragile positional slice that would silently misalign if _ACTIVITY_COLUMNS were reordered. Tests: test_1265_dashboard_perf updated for the slim dict shape (+ asserts the narrow projection); 9 pass. 93 pass across affected suites. Alembic graph validated: single head 0002 -> 0001 -> base. Related to #1265 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
7 tasks
This was referenced Jun 28, 2026
vybe
pushed a commit
that referenced
this pull request
Jun 28, 2026
…ntical responses (#1157) (#1368) * feat(loops): add no_progress_threshold column (dual-track migration) Adds the nullable `agent_loops.no_progress_threshold INTEGER` column for doom-loop detection. NULL = disabled (back-compat for in-flight loops). Dual-track per Invariant #9: SQLite `_migrate_agent_loops_no_progress` (idempotent `_safe_add_column`) + Alembic `0007_agent_loops_no_progress` (`ADD COLUMN IF NOT EXISTS`, chained after `0006_agent_reports`). DDL in both `schema.py` (SQLite) and `tables.py` (MetaData). `_loop_row_to_dict` + `LoopOperations.create_loop` carry the field through. Refs #1157 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(loops): detect no-progress doom loops in the runner The runner fingerprints each successful run's full response (`_fingerprint = sha256(" ".join(text.split()))` — collapses whitespace, preserves word boundaries, empty/whitespace-only fold to one fingerprint) and tracks consecutive identical results in a runner-local `last_fingerprint`/`repeat_count` (no persistence). Once K consecutive runs match, the loop stops with status `stopped` / `stop_reason="no_progress"`. Precedence: `stop_signal_matched` is checked first and wins; a pending `user_stopped` or passed deadline also outranks `no_progress` (re-checked before the break) so an explicit stop is never relabeled. Exact-hash only — no fuzzy/semantic similarity. NULL/0 threshold disables. Refs #1157 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(loops): accept no_progress_threshold on the loop API `StartLoopRequest` gains `no_progress_threshold: Optional[int]` (default 3, ge=0) with a `field_validator` rejecting `1` → 422 ("repeated identical" needs ≥2). The router threads it into `loop_service.start_loop()`, and `LoopStatusResponse` / `_build_status_response` carry it back through `GET /api/loops/{id}`. Refs #1157 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(mcp): expose no_progress_threshold on run_agent_loop Adds the `no_progress_threshold` param to the `run_agent_loop` MCP tool with a zod `.int().min(0).refine(v !== 1)` validator mirroring the backend (0 disables, default 3, 1 rejected). Threaded through `TrinityClient.startAgentLoop`. Refs #1157 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(loops): no-progress threshold field in LoopsPanel Adds the "No-progress threshold" number input (default 3, 0 disables) with helper text to the Run-loop form. The `0` disable sentinel is sent explicitly — a truthy guard would drop it. `formatStopReason` maps the new `no_progress` reason to a readable label. Refs #1157 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(loops): cover no-progress doom-loop detection ~13 unit tests: stop-at-K, near-miss counter reset, response normalization (whitespace/empty collapse), precedence over stop_signal / user_stop / deadline, K > max_runs, the WS event contract, router default-on, K=1 → 422, and GET round-trip of the threshold. Refs #1157 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(loops): document no-progress detection (#1157) requirements §38.3, architecture (Sequential Agent Loops + agent_loops schema/stop_reason enum), the run-agent-loop feature flow, and a feature-flows index row. Refs #1157 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(security): CSO --diff report for #1157 (0 findings) Refs #1157 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Eugene Vyborov <eugene@beingluminous.com>
12 tasks
6 tasks
vybe
pushed a commit
that referenced
this pull request
Jul 9, 2026
#1533) (#1554) * fix(channels): count inbound client messages on the Sharing-tab roster (#1533) The roster showed `message_count = 0` for every external client, `last_active` was frozen at `/login` time, and clients who never ran `/login` never appeared at all. All three share one cause: `get_or_create_chat_link` and `increment_message_count` (Telegram + WhatsApp) had zero callers outside the `database.py` facade. The shared inbound path writes `public_chat_sessions`, a different table, so `*_chat_links` rows were only ever created by `set_*_verified_email` — the `/login` flow. Note: the issue's Context paragraph states that `last_active` "is touched on inbound traffic via `get_or_create_chat_link`". It is not — that function was dead, and returns early for an existing row without writing anything. Revive the write path behind a default-no-op `ChannelAdapter.record_inbound_activity` hook, called once per delivered DM from `ChannelMessageRouter._handle_message_inner` at step 5c: after the access gate, so an unauthenticated stranger who messages the bot cannot create unbounded chat-link rows, and skipped for groups, since a chat link is keyed by (binding, user) and counting group traffic would list members who never DM'd the agent. Telegram and WhatsApp override it; Slack and VoIP inherit the no-op (Invariant #9). The call is best-effort — a counter write never blocks message processing. Replace `get_or_create` + `increment` with one atomic `INSERT … ON CONFLICT DO UPDATE` (`record_inbound`), which removes a cross-worker SELECT-then-INSERT race under `--workers 2`, halves the writes per message, and refreshes a stale display name via `COALESCE(excluded, existing)`. Since `increment_message_count` was `last_active`'s only live writer, one call fixes the count and the timestamp together. Delete the now-dead methods, their facade delegations, and the `_row_to_chat_link` helper they alone called. Historical counts are not backfilled; the roster's "Messages" header says so. Tests: dual-backend (SQLite + PostgreSQL) roster read-back 0->1->2, `last_active` advance, username backfill, and `/login` interplay through the real `db_backend` harness; the real `_handle_message_inner` for the DM, group, access-denied and counter-failure paths; and the adapter override bodies executed for real — mutation-verified, a typo'd metadata key turns the suite red. A facade-delegation guard covers the wholesale-mock blindness recorded in docs/memory/learnings.md. Verified against a live instance: real Telegram webhook payloads through the real transport drive the roster 0->1->2; a group message reaches the router and is not counted; an access-denied message creates no row. Follow-up #1552 records the read-time-derivation reframe (deriving the roster from public_chat_messages) that this tactical fix deliberately defers. Fixes #1533 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(channels): use an example.com placeholder email in the #1533 access-denied case The public-repo rule in CLAUDE.md calls for `user@example.com`-style placeholders. `a@b.com` was copied from the neighbouring access-gate test and was also inconsistent with the rest of this file, which already uses `alice@example.com`. No behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4 tasks
AndriiPasternak31
added a commit
that referenced
this pull request
Jul 26, 2026
* chore(.claude): bump dev-methodology submodule — Product Quality Bar
Points .claude at trinity-dev 57c8b5c: adds a canonical Product Quality Bar
section to DEVELOPMENT_WORKFLOW.md (six adoption/ease-of-use principles) and
hooks it into the dev pipeline — /create-issue (acceptance criteria),
/autoplan (scope calibration → taste decisions), /implement (build-time
checklist), and /review (new 4.15 catch-in-diff check).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(announcements): v0.8.0 announcement record (#1548)
* chore(.claude): bump dev-methodology submodule — Product Quality Bar
Points .claude at trinity-dev 57c8b5c: adds a canonical Product Quality Bar
section to DEVELOPMENT_WORKFLOW.md (six adoption/ease-of-use principles) and
hooks it into the dev pipeline — /create-issue (acceptance criteria),
/autoplan (scope calibration → taste decisions), /implement (build-time
checklist), and /review (new 4.15 catch-in-diff check).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(announcements): v0.8.0 release announcement record (all channels)
---------
Co-authored-by: Eugene Vyborov <eugene@beingluminous.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(avatar): prevent thinking-token truncation dropping the color-scheme block
Avatar refinement runs each identity prompt through GEMINI_TEXT_MODEL to append
a fixed technical block (background #1a1f2e/#111827, indigo rim #6366f1,
head-and-shoulders framing, 85mm lens, 5600K key) — the sole source of avatar
color scheme and cross-generation consistency.
The refinement call capped maxOutputTokens at 512. Since #1130 the default model
is gemini-3.5-flash, a *thinking* model whose reasoning phase draws from the
output budget. Reasoning consumed ~491 of 512 tokens, truncating the refined
prompt (finishReason=MAX_TOKENS) to a ~90-char fragment that dropped the entire
technical block. Avatars were then generated from a bare subject description —
wildly inconsistent and off-palette.
Fix in _call_gemini_text:
- Disable thinking (thinkingConfig.thinkingBudget=0) — refinement is a
deterministic rewrite that needs no chain-of-thought.
- Raise maxOutputTokens 512 -> 4096 as headroom even when thinking stays on.
- Retry once WITHOUT thinkingConfig on HTTP 400: a thinking-mandatory model
(e.g. gemini-2.5-pro) rejects budget=0, and refine_prompt silently falls back
to the raw prompt on error, so an unhandled 400 would re-break avatars.
Verified end-to-end against the live model: all 6 technical-block tokens now
survive refinement (was 0/6). Adds 3 regression tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(channels): count inbound client messages on the Sharing-tab roster (#1533) (#1554)
* fix(channels): count inbound client messages on the Sharing-tab roster (#1533)
The roster showed `message_count = 0` for every external client, `last_active`
was frozen at `/login` time, and clients who never ran `/login` never appeared
at all. All three share one cause: `get_or_create_chat_link` and
`increment_message_count` (Telegram + WhatsApp) had zero callers outside the
`database.py` facade. The shared inbound path writes `public_chat_sessions`, a
different table, so `*_chat_links` rows were only ever created by
`set_*_verified_email` — the `/login` flow.
Note: the issue's Context paragraph states that `last_active` "is touched on
inbound traffic via `get_or_create_chat_link`". It is not — that function was
dead, and returns early for an existing row without writing anything.
Revive the write path behind a default-no-op `ChannelAdapter.record_inbound_activity`
hook, called once per delivered DM from `ChannelMessageRouter._handle_message_inner`
at step 5c: after the access gate, so an unauthenticated stranger who messages the
bot cannot create unbounded chat-link rows, and skipped for groups, since a chat
link is keyed by (binding, user) and counting group traffic would list members who
never DM'd the agent. Telegram and WhatsApp override it; Slack and VoIP inherit
the no-op (Invariant #9). The call is best-effort — a counter write never blocks
message processing.
Replace `get_or_create` + `increment` with one atomic `INSERT … ON CONFLICT DO
UPDATE` (`record_inbound`), which removes a cross-worker SELECT-then-INSERT race
under `--workers 2`, halves the writes per message, and refreshes a stale display
name via `COALESCE(excluded, existing)`. Since `increment_message_count` was
`last_active`'s only live writer, one call fixes the count and the timestamp
together. Delete the now-dead methods, their facade delegations, and the
`_row_to_chat_link` helper they alone called.
Historical counts are not backfilled; the roster's "Messages" header says so.
Tests: dual-backend (SQLite + PostgreSQL) roster read-back 0->1->2, `last_active`
advance, username backfill, and `/login` interplay through the real `db_backend`
harness; the real `_handle_message_inner` for the DM, group, access-denied and
counter-failure paths; and the adapter override bodies executed for real —
mutation-verified, a typo'd metadata key turns the suite red. A facade-delegation
guard covers the wholesale-mock blindness recorded in docs/memory/learnings.md.
Verified against a live instance: real Telegram webhook payloads through the real
transport drive the roster 0->1->2; a group message reaches the router and is not
counted; an access-denied message creates no row.
Follow-up #1552 records the read-time-derivation reframe (deriving the roster from
public_chat_messages) that this tactical fix deliberately defers.
Fixes #1533
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(channels): use an example.com placeholder email in the #1533 access-denied case
The public-repo rule in CLAUDE.md calls for `user@example.com`-style
placeholders. `a@b.com` was copied from the neighbouring access-gate test and
was also inconsistent with the rest of this file, which already uses
`alice@example.com`. No behavior change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(ci): auto-trigger frontend-e2e (nightly + frontend paths), keep ui opt-in (#1526) (#1551)
The frontend-e2e Playwright suite ran ONLY on `ui`-labeled PRs, so most PRs never
triggered it and the suite rotted silently — specs broke against structural/UX
changes and sat red on `dev` for weeks (four documented instances: #1134, #1508,
#1378). The gate itself was the root cause.
Add automatic triggers so a red suite is always visible:
- Nightly `schedule` (07:00 UTC) on `dev` — the durable signal. A red nightly
opens/updates a single tracking issue (new `report` job, label
`frontend-e2e-nightly`), à la #1185; a green nightly closes it.
- Auto-run on any PR that touches `src/frontend/**` (dependency-free `changes`
job via `gh pr diff`), so frontend authors see their own breakage.
- The `ui` label still works as a manual opt-in on non-frontend PRs and to force a
run (preserved for the heavier @visual/@interactive tiers, #596).
- `workflow_dispatch` for on-demand runs.
DECISION (AC): advisory, NOT a required merge gate. The recurring failure class is
modal/overlay flake on a fresh zero-user-agent stack, so a hard gate like #715's
unit gate needs a flake budget first (#596). Recorded in the workflow header.
Per-job least-privilege permissions; concurrency keyed per-PR (cancel) vs per-ref
(schedule, no cancel). Only @smoke runs in CI, unchanged.
Related to #1526
* feat(mcp): make per-agent MCP connector OSS-core (trinity-enterprise#118 Part A) (#1555)
* feat(mcp): make per-agent MCP connector OSS-core (trinity-enterprise#118 Part A)
Relocate the per-agent MCP connector (ent#46/#55/#51, shipped v0.8.0 entitlement-
gated as `mcp_connector`) from the private enterprise submodule into OSS core, and
drop the entitlement gate front and back. Decision (Eugene, 2026-07-09): sharing
agents via individual MCP connectors is a platform-adoption surface, not a paid
module.
Backend (router → service → db, Invariants #1/#2/#14):
- routers/connector.py — /api/agents/{name}/connector* (config, mint/regenerate/
revoke key, playbooks), mounted unconditionally in main.py; no requires_entitlement.
- services/connector_service.py — snippet builder + playbook resolution.
- db/connector.py (ConnectorOperations) — config CRUD + scoped-key mint/revoke
into mcp_api_keys (scope='connector'); facade delegators on database.py.
- models.py — ConnectorConfigUpdate/Status/KeySecret/Playbook/ClientSnippet.
Schema: enterprise_connectors table re-homed onto OSS dual-track (db/tables.py,
db/schema.py, db/migrations.py:enterprise_connectors_table + Alembic
0015_enterprise_connectors). Name kept so existing enterprise installs adopt their
data with zero migration (CREATE TABLE IF NOT EXISTS, no duplicate-table drift).
Delete/rename cascade via an enterprise_connectors AGENT_REF.
Frontend: ConnectorChannelPanel un-gated in SharingPanel.vue (dropped the
isEntitled('mcp_connector') v-if + the now-unused enterprise store wiring).
The MCP proxy tools (connector.ts), the connector-scope auth fence
(dependencies.py), and ExposedToolsPanel.vue were already OSS and edition-agnostic.
`mcp_connector` is removed from the entitlement registry by the paired enterprise
PR (deletes register_module).
Docs: requirements mcp.md §7.5 + feature-flows/mcp-connector.md + architecture/index.
Tests: tests/unit/test_118_mcp_connector_oss.py (11) — service helpers, config CRUD,
key mint/regenerate/revoke, scope='connector' validate contract.
Part B (email-auth onboarding, #848) deferred pending design sign-off.
Related to trinity-enterprise#118
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(submodule): bump enterprise to post-#121 so the connector isn't double-mounted (#118)
#1555 makes the per-agent MCP connector OSS-core. The enterprise submodule was
pinned at 630cca9e, which still ships backend/mcp_connector/ and registers it via
register_enterprise() — so an enterprise build would double-mount the connector
router alongside the new OSS-core one. Bump the pin to f5d69be6 (enterprise main
post trinity-enterprise#121), where the private module is removed.
This forward-integrates two already-on-enterprise-main commits into the pin:
- trinity-enterprise#121 — removes backend/mcp_connector/ (the intended pair)
- trinity-enterprise#120 — ENTERPRISE_LOCAL_DEV docs (docs only)
- trinity-enterprise#106 — client-portal umbrella (already on enterprise main)
OSS-only CI is unaffected (submodule is update=none / "boots without enterprise").
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(enterprise): explain the private feature catalog + runtime verification
ENTERPRISE.md documents the open-core seam mechanism (correct) but never
said WHERE the feature-level OSS/enterprise split lives or WHY it isn't
here. Add a short "Why there's no feature catalog here" note: the standing
rule trinity-enterprise#45 (enforced by enterprise-docs-guard.yml) keeps
the paid-feature catalog private, and entitled customers find it in the
private enterprise repo. Point readers at the runtime source of truth for
"what's enabled on this instance" (GET /api/version + feature-flags).
Mechanism-only, no named features — enterprise-docs-guard grep verified green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(announcements): webinar video announcement record 2026-07-10
Record of the "sovereign, isolated AI agents per client" webinar
video announcement (https://youtu.be/8w98dA6hDew) sent via /announce
to Discord, Slack, Telegram, Twitter/X (trinity + default), and
GitHub Discussions.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(deps-dev): bump @types/node (#1531)
Bumps the patch-and-minor group in /src/mcp-server with 1 update: [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node).
Updates `@types/node` from 26.1.0 to 26.1.1
- [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.1.1
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>
* fix(reliability): clear per-agent Redis runtime state across the agent lifecycle (#1560) (#1568)
* fix(reliability): clear per-agent Redis runtime state across the agent lifecycle (#1560)
`agent:circuit:{name}` (transport breaker) is keyed by agent NAME, not container
identity, and carries no TTL. Nothing in the lifecycle cleared it, so a container
replaced under the same name inherited its predecessor's `dormant` verdict and
fast-failed every execution with "Agent circuit breaker open — agent is
unhealthy" — without the backend ever contacting the agent.
Adds `services/agent_runtime_state.py` as the single enumeration point for every
name-keyed per-agent Redis keyspace (the Redis-side twin of the `AGENT_REFS`
registry in `db/agent_cleanup.py`), with two entry points whose blast radii
differ by whether a container is running:
clear_agent_breakers heartbeat + transport circuit + dispatch breaker
(safe on a live container)
clear_agent_runtime_state the above + execution slots
(teardown paths only — force_clear_slots would drop
capacity accounting for an in-flight #1083 execution)
Wired into six lifecycle points. The issue's acceptance criteria named delete,
rename and create; none is the reachable path:
* create is unreachable — `is_agent_name_reserved` sees soft-deleted rows and
409s, locking the name for the whole retention window;
* the name only unlocks at the retention purge, which cleared no Redis state;
* the reachable path is `start_agent_internal`, whose `needs_recreation` branch
replaces the container on any config drift (subscription switch, resource
change, auth-token rotation), so one fleet-wide rotation resurrects every
stale verdict.
So start/recreate, purge, and the `trinity-system` bootstrap were added beyond the
written criteria. The start-path clear runs before the recreate, since
`containers_run(detach=True)` brings the replacement up, and is guarded on
`needs_recreation or not was_already_running` so a no-op start cannot reset a
breaker protecting a wedged agent.
Tests: a bidirectional parity guard fails CI when a new `agent:*` keyspace ships
unregistered; wiring tests pin all six call sites and that lifecycle.py never
clears slots; an integration test exercises the real Lua (fakeredis has no
EVALSHA) plus an opt-in leg driving the full recreate path over HTTP. Both guards
are mutation-tested.
Complementary to #1561, which removes the source of breaker poisoning; this
removes the inheritance. Both are needed.
Fixes #1560
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(1560): stop bare sys.modules mutations in the new test files (#762)
The `lint (sys.modules pollution check)` CI gate flagged 7 new violations.
They were real, not a lint technicality: the integration test replaced
`sys.modules["services"]` with a bare stub module and never restored it,
so every later test in the same session that imported the real `services`
package would have seen the stub — the exact cross-file pollution class
#762 introduced this gate for.
- Unit files: drop the `sys.modules[mod_name] = module` registration in
`_load` entirely. `agent_runtime_state.py` is a stdlib-only leaf with no
`@dataclass` needing `sys.modules[cls.__module__]` to resolve annotations,
so the registration bought nothing and risked pollution. Per-test stubs
already go through `monkeypatch.setitem`, which restores itself.
- Integration file: keep the bindings (they are what makes the production
lazy imports resolve to the real modules against real Redis — monkeypatch
cannot reach an import performed inside the function under test) and add
the sanctioned `_STUBBED_MODULE_NAMES` + autouse `_restore_sys_modules`
snapshot/restore pair, per tests/unit/test_telegram_webhook_backfill.py.
Verified: `python tests/lint_sys_modules.py` clean; running the integration
file followed by a suite that imports the real `services` package passes.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(ui): prettify Agent Permissions Matrix — bigger labels, no grid jump (#1563)
The fleet permissions matrix was hard to read (text-xs, 32px cells) and
the grid jumped on every grant/revoke: the toast rendered inline between
the toolbar and the grid, so it pushed the grid down on appear and
snapped it back on the 3.5s auto-dismiss.
- Bump base font (text-xs → text-sm), cells (w-8 h-8 → w-11 h-11),
checkmark (text-base), row/column agent labels (font-medium), corner
hints (10px → text-xs), and column-label headroom (8rem → 10rem).
- Move the toast into a reserved min-h-[2.5rem] slot so appear/dismiss
no longer reflows the grid.
Presentation-only — grant/revoke logic and endpoints unchanged.
Grant/revoke verified end-to-end via the backend API.
Related to #1562
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(sync-health): exclude soft-deleted agents from list_git_enabled_agents (#1561) (#1565)
`list_git_enabled_agents()` selected from agent_git_config on sync_enabled
alone, without joining agent_ownership or excluding deleted_at. Soft delete
keeps the git_config row, so the 60s SyncHealthService poller kept issuing
HTTP calls to removed containers forever — each httpx.ConnectError poisoned
the transport circuit breaker, eventually driving it DORMANT and emitting a
bogus circuit_breaker_dormant operator-queue alert for a nonexistent agent.
GET /api/fleet/sync-audit (same accessor) also listed dead agents.
- Join agent_ownership + filter deleted_at IS NULL, mirroring the #834
hardening on list_all_enabled_schedules(). Fixes both consumers at once.
- git_service.get_git_status: bare print() → logger.warning (structured).
- Regression tests: soft-deleted agent is excluded from the accessor and
never polled (zero HTTP, no sync_state row, no operator-queue entry).
Audit sweep (per AC): list_git_enabled_agents was the sole background-loop
accessor missing the deleted_at filter. Other per-agent HTTP pollers
(monitoring, operator-queue) enumerate from Docker, so removed containers are
inherently excluded; scheduler already filters (#834); capacity drain is
DB-only (no agent HTTP). Companion breaker-inheritance defect tracked in #1560.
Related to #1561
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(canary): execution-row integrity invariants E-03, G-03, E-04, G-04 (Phase 4) (#1497)
* test(canary): unblock canary_invariants fixture broken by #1472 merge
Two pre-existing breakages landed via #1472 (367a5e12, merged today) in
root-level tests/test_canary_invariants.py — a file the CI unit job
(tests/unit/ only) does not run, so they merged undetected:
1. Duplicate `CREATE TABLE agent_schedules` in the `canary_db` fixture DDL
— the E-06 work added a second definition next to the pre-existing one,
so `executescript` raised "table agent_schedules already exists" and
every fixture-dependent canary test errored. Removed the older, unused
block (its `message`/`owner_id` columns have no consumer; `_add_schedule`
and the E-06/L-03 collectors use only the kept block's columns).
2. `test_run_invariants_all` expected registry set omitted "E-06" (added to
the invariants registry by the same PR). Added E-06 to the expected set
and asserted it is green on a clean platform.
Restores a green baseline (92 passed) so the #1450 B-01 work can be verified.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(canary): B-01 queue-status coherence — backend-consistent Side B + confirm-re-read (#1450)
B-01 compared two reads that were neither temporally atomic nor
backend-consistent (both latent while the canary is default-OFF and SQLite
makes the two reads hit one file):
(a) Temporal non-atomicity — a concurrent enqueue/backlog-drain landing
between the two reads produced a transient count mismatch → spurious
critical + green→red Slack alert.
(b) Backend divergence (#300/#1093) — Side A (`db.get_queued_count`) honors
`get_engine()`/DATABASE_URL; Side B read raw sqlite3 at DB_PATH. On
Postgres those are two different databases, so B-01 compared Postgres
truth to a stale/absent SQLite file (fatal under the Postgres direction +
SQLite EOL, #1278).
Production-side residue of #1446 (PR #1452), which fixed only the test-harness
`sys.modules` leak and deferred these two gaps.
Fix (localized to B-01):
- New `_collect_queued_ids_via_engine` reads B-01's Side B through the SAME
`get_engine()` seam as the accessor, on a dedicated `queued_ids_via_engine`
snapshot field. Independent code path (SELECT id/literal 'queued' vs
COUNT(*)/the QUEUED enum) — shares a database, not a code path, so a
cache/status-filter regression still surfaces (non-tautology, AC #3). No
cache / second count of the queue (AC #4).
- The collector performs one confirm-re-read on a mismatch: a transient race
self-heals; a persistent drift survives and fires (AC #2). An engine-read or
unconfirmable confirm degrades to a B-01 skip — it never compares an engine
count against the raw-sqlite id-set (the blocker the reviews surfaced).
- `queued_exec_ids` (raw sqlite) stays untouched for B-02/E-02, so blast
radius is B-01-only and the sibling #1077 merge stays clean.
The running-side / known-agents reads remain raw-sqlite (half-migrated
collector); the collector-wide migration + a dark-canary tripwire are a filed
follow-up.
Tests (test_canary_invariants.py): retarget the synthetic B-01 tests at
`queued_ids_via_engine`; add the AC #5 regression net — a diverged-backend
proxy (raw ≠ engine temp files) that false-fires pre-fix and is green
post-fix, a transient-race confirm-absorb + persistent-drift-still-fires pair,
and an engine-read-failure→skip test. New split fixtures (`canary_db_split` /
`reload_canary_split`) model the diverged backend without a live PG. Convert
the file to the sanctioned `_STUBBED_MODULE_NAMES` + `_restore_sys_modules`
escape hatch so its import-time stubs no longer leak (the #1446 mechanism) and
the reimport `del`s are lint-clean.
Docs: architecture.md Canary B-01 row updated for the engine-backed read path
+ confirm-re-read.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* docs(feature-flows): add #1450 canary B-01 Recent Updates row
Sync-feature-flows: canary-internal change, no dedicated flow doc
(architecture.md is canonical); append one Recent Updates row.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test(canary): fix duplicate agent_schedules DDL + reconcile E-06 in registry test
The canary_db fixture had two `CREATE TABLE agent_schedules` statements in one
executescript (a #1472 merge artifact), so it raised `table already exists` and
reddened the whole file. Merge them into one definition carrying every column
the canary reads (next_run_at/enabled/deleted_at + agent_name). Add
duration_ms/queued_at/backlog_metadata to schedule_executions and extend
_add_execution for the #1077 E-03/E-04 collector work. Reconcile E-06 (already
registered) into test_run_invariants_all's expected key-set.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(canary): terminal-row collector for E-03/G-03 (#1077)
Add _collect_terminal_rows(window_seconds) + Snapshot.terminal_rows. Windowed on
started_at (not completed_at, so E-03 can see NULL-completed_at rows), scoped to
success/failed/cancelled via a local _E03_TERMINAL_STATUSES subset that excludes
skipped (which legitimately has no completed_at/duration_ms). PRAGMA guard skips
the source entirely when completed_at/duration_ms are absent (column-absent !=
value-NULL) rather than false-firing. Window = max per-agent timeout + 300s;
bounded ORDER BY started_at DESC LIMIT 5000 with a logged sampled flag (no
(status,started_at) index over 90-day retention; tripwire, not backfill audit).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(canary): register E-03 (completed_at populated) + G-03 (clock sanity) (#1077)
E-03 (A/major): terminal rows must have completed_at NOT NULL. Predicate is
completed_at-only — the catalog's + duration_ms clause false-fires on healthy
queue-terminated rows (cancel/fail/expire set completed_at but never
duration_ms). G-03 (A/minor): started_at <= completed_at with a ~1s cross-worker
clock-skew tolerance, UTC-aware parsing (E-06 _to_utc shape) so a #1474 mixed
naive/Z pair compares without raising. Both are leading-edge tripwires over the
shared terminal-row collector and catch all producers incl. the standalone
scheduler's raw-SQL writers a unit test never exercises.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test(canary): E-03/G-03 synthetic + collector + end-to-end coverage (#1077)
Per-invariant synthetic tests (holds-clean, fires-on-violation), collector tests
(started_at window in/out, NULL-completed_at still collected, skipped-status
excluded, column-absent DDL -> unavailable, LIMIT cap + sampled flag), and
end-to-end collect_snapshot tests through the real collector: the C1
cancelled-from-queue holds-clean guard (completed_at set, duration_ms NULL ->
zero E-03), half-written fires E-03, bad-clock fires G-03, sub-second skew does
not, and the #1474 naive-vs-Z compare-without-raising case.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* docs(canary): document E-03/G-03 Phase 4 invariants (#1077)
architecture.md canary table gains E-03/G-03 rows + Phase 4 lookup-key line;
requirements/infrastructure.md §31 gains a Phase 4 bullet (and reconciles the
stale 'Phase 2 deferred' note now that #882/#1472 shipped);
orchestration-invariant-catalog.md annotates E-03/G-03 as shipped with explicit
registry-id mapping, notes the E-03 completed_at-only predicate deviation and
G-03 started_at<=completed_at reduction, and flags the catalog-id vs registry-id
E-06 drift (catalog #129 != registry #1472).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* docs(feature-flows): index row for canary Phase 4 E-03/G-03 (#1077)
Canary is an internal invariant harness (no user-facing flow / dedicated flow
doc); follows the #1446 precedent of a Recent Updates row pointing at
architecture.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(canary): queued-row metadata collector for E-04/G-04 (#1077)
Capture queued_at + backlog_metadata for status='queued' rows in the
existing _collect_executions query, keyed by execution_id in a new
AgentSnapshot.queued_meta map. Both columns are PRAGMA-guarded (added by
BACKLOG-001): when either is absent on an older/minimal DDL the map is
left empty so E-04/G-04 skip those eids (older-image fail-open). Scoped
STRICTLY to queued rows — never terminal — so #1449's deferred
terminal-row backlog_metadata NULL-out cannot make E-04 false-fire.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(canary): register E-04 (queued metadata) + G-04 (no creds in metadata) (#1077)
E-04 (Tier A, major): every status='queued' row has queued_at NOT NULL
AND a non-NULL, JSON-parseable backlog_metadata — the
backlog_service.drain_next replay contract. A malformed blob raises
JSONDecodeError and stalls the FIFO. Reports only the failed-predicate
reason code + ids, never the raw metadata (may carry credentials;
violations persist to canary_violations).
G-04 (Tier A, critical): a queued row's backlog_metadata matches no
known secret prefix (sk-/ghp_/gho_/ghs_/ghu_/github_pat_/xoxb-/xoxp-/
AKIA/AIza/sk_live_), word-boundary anchored so common substrings don't
false-fire. Rides E-04's collected bytes. Reports only the matched
pattern NAME + ids, one violation per row (stops at first match) — never
the secret, surrounding bytes, or raw metadata.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test(canary): E-04/G-04 synthetic + collector + end-to-end coverage (#1077)
Collector test proves queued_meta is populated for queued rows only (a
terminal row carrying backlog_metadata is excluded — #1449-safe). E-04:
holds on valid rows; fires with the right reason code on NULL queued_at,
NULL backlog_metadata, and non-JSON metadata; skips an eid absent from
queued_meta (older-image fail-open); e2e over a real temp DB. G-04:
holds on benign metadata (incl. "task-" substring that must not
false-fire); fires on github_pat / openai / slack / aws exemplars; skips
NULL metadata (E-04 owns it). Every G-04/E-04 test asserts the secret /
raw metadata bytes appear NOWHERE in the persisted violation record. The
runner test now expects E-04 + G-04 in the registered invariant set.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* docs(canary): document E-04/G-04 Phase 4 invariants (#1077)
architecture.md lookup-key table gains E-04 + G-04 rows and the Phase 4
line now lists all four (E-04/G-04 stacked on #1450). requirements
infrastructure.md Phase 4 bullet expands to the full four-predicate set
with the credential-safety note. Catalog flips E-04/G-04 from
"gated on #1450" to SHIPPED, records the json.loads-vs-json_valid
implementation note, the queued-only scope, older-image fail-open, and
the report-reason/pattern-name-only security discipline; G-04 notes the
implemented check covers the backlog half of the title (log-line
scanning out of scope for #1077).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* docs(feature-flows): index row for canary Phase 4 E-04/G-04 (#1077)
Also corrects the header/separator ordering left malformed by the
earlier E-03/G-03 index-row commit (a data row had slipped above the
|---| separator).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Eugene Vyborov <eugene@beingluminous.com>
* fix(config): forward OPERATOR_INTAKE/DO_NOT_TRACK, DISPATCH_BREAKER_ENABLED, PUBLIC_ACCESS_REQUESTS_ENABLED through compose (#1485) (#1493)
* fix(config): honor cross-tool DO_NOT_TRACK convention for operator intake (#1486)
The operator-intake kill switch only disabled the outbound POST when
DO_NOT_TRACK was one of {"1","true","True"}, so DO_NOT_TRACK=yes|on|2|TRUE
leaked despite the obvious opt-out intent — breaking the very
consoledonottrack.com convention config.py's own comment cites.
Flip to a tracking-allowed whitelist: any value not in {0,"",false}
(case/space-insensitive) disables intake. Unset -> "0" -> tracking
allowed (unchanged). Add tests/unit/test_1486_do_not_track_truthiness.py
so a revert to the exact-tuple check fails loudly.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(config): forward operator-intake, dispatch-breaker & public-access levers through compose (#1485)
Same packaging-gap class as PR #1067 (VOIP vars): a lever read by
src/backend/config.py that neither compose file forwards into the backend
container, so setting it in .env silently no-ops (compose reads .env only
for ${VAR} interpolation, and neither backend service uses env_file).
Forward in both docker-compose.yml and docker-compose.prod.yml backend
environment: blocks, mirroring the existing PUBLIC_CHAT_URL idiom:
- PUBLIC_ACCESS_REQUESTS_ENABLED (#1488) — secure default false
- OPERATOR_INTAKE_ENABLED / DO_NOT_TRACK / OPERATOR_INTAKE_URL (#1486)
privacy kill switch. OPERATOR_INTAKE_URL keeps its FULL non-empty
default — a bare :- would arrive set-but-empty and shadow the code
default (#1076 set-but-empty class).
- DISPATCH_BREAKER_ENABLED (#1487) — global gate for the #526 dispatch
breaker; without it the owner-facing PUT .../circuit-breaker toggle
silently no-ops (two-tier gating needs both flags on).
.env.example: add the missing DISPATCH_BREAKER_ENABLED block (documenting
the two-tier gate) and a settable DO_NOT_TRACK=0 line. Backend-only —
src/scheduler reads none of these five vars.
Fixes #1486
Fixes #1487
Fixes #1488
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* docs(feature-flows): note DISPATCH_BREAKER_ENABLED compose-forwarding fix (#1487/#1485)
The dispatch-circuit-breaker flow already documented the DISPATCH_BREAKER_ENABLED
env var, but before this PR that var never reached the container (the
#1039/#1067 packaging-gap class), so a reader following the doc would set it and
the owner toggle would silently no-op. Add a one-line Config-surface accuracy
note + a Recent Updates index row.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Eugene Vyborov <eugene@beingluminous.com>
* fix(scheduler): serialize timestamps as UTC 'Z' so non-UTC browsers show correct relative times (#1474) (#1496)
* fix(scheduler): serialize timestamps as UTC with explicit 'Z' (#1474)
The standalone scheduler wrote execution/schedule timestamps via
datetime.utcnow().isoformat() (no 'Z'), while the backend writes through
utc_now_iso() (Z-suffixed). In a non-UTC browser, JS new Date(naive) parses
the naive string as local time, shifting schedule-triggered rows by the
viewer's UTC offset. Data on disk was fine; only display was wrong.
Vendor a byte-parity mirror of the backend timestamp helpers into
src/scheduler/utils.py (same regenerate-from-backend discipline as
failure_classifier.py): utc_now_iso / to_utc_iso emit 'Z'; parse_scheduler_ts
reads tolerantly and returns naive UTC.
Write + read land together (atomic): once writes emit 'Z',
datetime.fromisoformat("...Z") returns a tz-aware value on 3.11+, and the
duration math (datetime.utcnow() - started_at) would raise aware-naive.
parse_scheduler_ts converts-then-strips so the historical naive model type is
preserved and the subtraction stays naive-naive. All 24 read-parses route
through it; every write site (started_at/completed_at/last_run_at/next_run_at/
retry_scheduled_at/validated_at + process-schedule variants) emits 'Z'.
Updates the #1472 next_run_at comments to note the mapper now returns
naive-UTC (instant preserved), so both compare branches stay correct.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(executions): normalize naive timestamps to UTC 'Z' at read boundary (#1474)
The summary/list readers returned raw dict(row) values straight from the DB,
so a scheduler-written naive started_at/completed_at serialized naive out of
Pydantic and JS new Date(naive) parsed it as local time — the reported
schedule-triggered relative-time shift. This fixes historical rows for all
consumers at the source (unit-testable, unlike the frontend layer).
Reuse the existing parse_iso_timestamp helper (assume-UTC for naive) + the
Z-emitting to_utc_iso — the same normalization the already-correct sibling
readers (_row_to_execution) apply — via a small _norm_ts shim:
- db/schedules.py: get_agent_executions_summary (TasksPanel),
get_fleet_executions (ExecutionsPanel), get_agent_schedules_summary
last_run_at (Overview/Schedules).
- db/activities.py: _row_to_activity / _mapping_to_activity
(UnifiedActivity) started_at/completed_at/created_at.
None passes through untouched. No data migration.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(ui): parse backend timestamps as UTC in the 5 execution panels (#1474)
Defense-in-depth for the read side (covers WebSocket-pushed timestamps and any
un-audited endpoint, alongside the backend read-boundary fix). Each panel
hand-rolled `new Date(backendStr)`, which parses a naive (no-'Z') string as
*local* time. Replace only the internal parse with the shared idempotent
`parseUTC` (appends 'Z' when no tz indicator present → correct for legacy-naive,
new-'Z', and '+00:00' alike); `new Date()` "now" calls are left untouched and
per-panel display formats are unchanged.
- TasksPanel: formatRelativeTime + inline log-detail timestamp
- SchedulesPanel: formatRelativeTime, isOverdue, formatOverdue, formatDateTime
- ExecutionsPanel: timeAgo
- UnifiedActivityPanel: formatTime
- OverviewPanel: fmtDateTime
Established pattern (stores/network.js, ReplayTimeline.vue).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* docs: record #1474 scheduler Z-suffix + read-boundary under Invariant #16
Extend Architectural Invariant #16 (the #476 ISO-Z rule) with its write-side/
read-boundary cousin: the scheduler vendors src/scheduler/utils.py (Z-suffixed
writes, naive-UTC tolerant reads); the leaking backend read boundaries normalize
via parse_iso_timestamp; the 5 panels parse via parseUTC. Notes honestly that
next_run_at stays mixed-format across writers (safe — Python-compared only) and
that main.py SchedulerStatus.last_check is out of scope.
Update the #1472 learnings entry: the scheduler DB mapper now returns naive-UTC
(convert-then-strip, instant preserved), so the "schedules store AWARE
next_run_at" premise is true of the backend writer, not the scheduler read path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* docs(feature-flows): document #1474 scheduler Z-suffix timestamp contract
Add a "Timestamp serialization contract (#1474)" subsection to
scheduler-service.md (vendored utils.py, Z writes, naive-UTC tolerant reads,
backend read-boundary + panel normalization, next_run_at mixed-format caveat)
plus a Revision History row; add the index Recent Updates row.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test(scheduler): cover _update_business_status validated_at Z-suffix (#1474)
The service-layer VALIDATE-001 write path (service.py::_update_business_status)
is the one #1474 timestamp write site outside database.py. Add sibling-path
coverage per the incomplete-fix rule: assert validated_at is stored 'Z'-suffixed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Eugene Vyborov <eugene@beingluminous.com>
* docs(user-docs): add Trinity FAQ — 264 grounded Q&As across 14 topic pages
One page per topic (getting started, agents, chat/sessions, credentials,
scheduling, collaboration, channels, MCP/API, operations, sharing,
deployment, security, advanced, troubleshooting) plus a generated
question index. Answers derived from user docs and verified against
code; .claude pointer picks up the generate-user-docs FAQ step.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(user-docs): clarify PAT scope — git transport vs gh CLI/REST API authentication
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(helper-mcp): standalone Trinity docs Q&A MCP server (#1459)
New src/helper-mcp/ package (@abilityai/trinity-docs-mcp): an npx-runnable
stdio MCP server exposing the public ask-trinity docs Q&A endpoint
(DOCS-QA-001) to any MCP client — no Trinity instance or API key required.
- Tools: ask_trinity (multi-turn; detects the endpoint's silent session
reset and warns when context was lost) + get_agent_requirements (agent
guide fetched live from GitHub, quick-reference fallback)
- Guards: 4k question cap, 50s abort timeout, no auto-retry,
redirect:"error", non-JSON guard, structured error text; session_id
handled as opaque string (live values exceed 2^53)
- Deps: official @modelcontextprotocol/sdk + zod only; console.error-only
logging (stdout is the JSON-RPC channel); Node >=18 launcher guard
- Tests: 26 unit (mocked fetch) + pack-and-run stdio smoke test asserting
JSON-RPC stdout purity; CI workflow helper-mcp-test.yml
- Publish: publish-helper-mcp.yml (npm provenance; documented one-time
manual first-publish bootstrap for trusted publishing)
- Corpus: sync-docs-to-vertex.yml now also indexes the agent guide;
feature flow corrected (user-docs/FAQ were already indexed) and extended
with the verified endpoint contract (no citations field, silent session
expiry)
- Docs: requirements/mcp.md entry, README + user-docs install blocks,
package README
Fixes #1459
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(agents): ephemeral ghost agents — budgeted hard-discard lifecycle + spawn provenance (trinity-enterprise#69)
Disposable "ghost" agents: created with a hard budget (max_executions and/or
TTL — expiry ALWAYS stamped, ceiling 24h default), volume-less (container
writable layer; ghosts never recreate), hard-discarded at budget with no
soft-delete/retention/name-reservation. Creation is entitlement-gated
(ephemeral_agents; fail-closed — inert until the enterprise module registers);
all lifecycle mechanics are edition-agnostic OSS primitives.
Lifecycle:
- Schema: 5 additive agent_ownership columns (is_ephemeral, budget, expiry,
spawned_by_agent/key_id); dual-track migration (SQLite + Alembic 0016)
- Creation gates (crud.py): entitlement 403 → ephemeral-caller refusal
(chain-spawn kill) → per-parent spawn rate limit → TTL ceiling 400 →
server-suffixed name (hex8) → atomic per-owner Redis quota (INCR-with-cap,
NX reseed, DB fallback); ghosts skip volume/avatar/cred-injection/auto-sync,
default max_parallel_tasks=1
- Budget: gate at the TOP of CapacityManager.acquire (terminal+active >= max
or expired ⇒ EphemeralBudgetExhausted → 410 Gone / FAILED
ephemeral_exhausted; covers every admission surface); post-CAS-win
apply_result hook (backgrounded, fail-open) triggers discard at budget
- Hard discard (services/agent_service/ephemeral.py): SETNX-locked,
crash-convergent — intent marker → CAS-fail non-terminal rows
(ghost_discarded) → force-remove container → clear Redis state BEFORE purge
→ cascade purge (executions KEEP) → audit. DELETE routes ghosts here before
the container lookup (half-discarded state force-discardable)
- GC (cleanup_service._sweep_ephemeral_agents): DB pass + Docker-as-truth
orphan pass with 15-min newborn grace; capped per cycle
Part 2 — spawn provenance + parent control:
- Any agent-spawned creation persists spawned_by_agent/key_id and auto-grants
the agent_permissions parent→child edge (created_by="spawn:{parent}") so a
parent can immediately chat/list/info the child it spawned
- BEHAVIOR CHANGE: agent-scoped keys may start/stop/delete ONLY agents they
spawned (name AND key-id match; interim until #948); sharing, permission
grants, rename, and credential ops are now human-only (403 for agent keys)
- Ghost-key containment fence at the single auth entry point: a ghost's own
key reaches only heartbeat/result-callback/reports/notifications/self-info
Fleet hygiene: heartbeat watch + fleet health exclude ghosts; operator-queue
polling keeps them; exec/cost stats stay inclusive; schedules on ghosts → 400;
AgentStatus.ephemeral surfaced + GHOST badge.
Tests: tests/unit/test_69_ephemeral_agents.py (40, db_harness real-engine) —
accessors, facade delegations, acquire-gate matrix, key-fence matrix, Part 2
guard matrix, budget hook, discard idempotency/crash-convergence, atomic
quota. Full unit suite verified; residual order-dependent flakes reproduced
on clean dev (pre-existing, documented in learnings.md).
Refs abilityai/trinity-enterprise#69 (Phase 0 record on the issue; closed
manually at release — cross-repo keywords don't auto-close).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(69): patch _REAL_MODULES objects directly, never string targets — fixes seed-12345 order flake
CI's regression-diff (head seed 12345) caught the discard test flaking:
string-form monkeypatch targets resolve through sys.modules at PATCH time,
which under some pytest-randomly orderings is a sibling test's leaked stale
entry — the patch lands on the wrong module object while discard's call-time
import (under the _own_real_modules pin) resolves the real one, so
get_agent_container fell through to the real function (container=None,
removal skipped). Same hazard removed from the gated_capacity and
ghost_fence fixtures (bare fixture-time imports), and the audit assertion
moved to an instance-method patch on the pinned singleton.
Verified: full suite green under all three CI seeds (12345/67890/99999),
3775 passed each.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(learnings): string-target monkeypatch resolves stale sys.modules entries — patch by object
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(tests): pin real modules in TestAcquireCeilingClamp to kill sys.modules-leak flake (#1582) (#1584)
The trio (test_acquire_clamps_above_ceiling / test_get_slot_state_clamps /
test_get_all_states_clamps_each) failed order-dependently under
pytest-randomly on clean dev (5/7 observed runs), passed in isolation and
under -p no:randomly.
Root cause (the #762/#1446 family): `_patch_ceiling` patched
`get_max_parallel_tasks_ceiling` on the settings_service resolved via
`from services import settings_service`, but `capacity_manager.acquire`
imports `clamp_to_ceiling` at call time from `services.settings_service`.
When a sibling's module-level stub (e.g. test_fleet_status_resilience
installing a fresh `services` package with a real `__path__`) leaves two
module objects for `services.settings_service` in play, the patch lands on
one and `clamp_to_ceiling.__globals__` reads the other — the patch is
silently never hit and the clamp assertion sees the unpatched (default)
ceiling.
Fix (the pattern documented for this leak family):
- Capture the real `services` / `services.settings_service` /
`services.capacity_manager` at collection time (this file sorts before
every known leaker, so the import is leak-free).
- Autouse fixture re-pins them into sys.modules per test (monkeypatch
auto-restores) so the code-under-test's call-time import and the test's
patch target resolve to the SAME object — last-write-wins over any leak.
- `_patch_ceiling` now patches the captured real module object directly,
never a bare re-import that could resolve a leaked stub.
Verification: 25/25 in the file; 4/4 trio; 32 passed across 6 random seeds
with test_fleet_status_resilience co-resident; 111 passed / 0 ceiling
failures across 5 full-unit randomized seeds.
Related to #1582
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(reliability): decouple agent autonomy from the circuit breaker (#1557) (#1571)
Disabling autonomy called force_circuit_dormant, parking the transport
circuit breaker dormant. The execute_task gate consults that breaker for
every trigger, so a healthy paused agent fast-failed all inbound chat
(manual/Telegram/Slack/public) with "circuit breaker open — agent is
unhealthy" — never contacted. Autonomy governs proactive work only; it
now acts solely via set_schedule_enabled and never touches the breaker.
#631's flood protection is unaffected (the breaker's own failure-driven
dormant path + #1464 leader lock + #1121 monitoring-default-off).
Also splits the misleading fast-fail message to name the breaker that
fired (transport = unreachable, dispatch = auth-dead).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Eugene Vyborov <eugene@beingluminous.com>
* feat(voice): voice replies v2 — per-message capability, agent-level config, runtime ElevenLabs key (trinity-enterprise#117) (#1549)
* feat(voice): voice replies v2 — per-message capability, agent-level config, runtime ElevenLabs key (trinity-enterprise#117)
Rework outbound voice replies (ElevenLabs TTS) along three lines:
1. Voice is a per-message capability, not a hard rule. Channel replies are text
by default; a reply becomes a voice note only when the agent explicitly calls
the new send_voice_reply MCP tool during the turn. The backend resolves the
channel destination from the execution (new schedule_executions.source_channel*
columns), gates on TTS availability + agent enable + per-channel flag, wraps
delivery in effect_guard (#1084), and reuses each channel's send primitive.
The always-voice adapter path (_maybe_send_voice) is removed.
2. Voice config moves to agent Settings (enable + voice selection, one place);
channel panels keep only a per-channel on/off flag
(agent_ownership.tts_voice_{telegram,slack,whatsapp}_enabled, default ON).
GET/PUT /api/agents/{name}/voice-replies extended with channels + effective voice.
3. ElevenLabs API key + platform default voice are runtime-configurable in admin
Settings (GET/PUT /api/settings/elevenlabs), key stored AES-256-GCM encrypted
and surfaced as configured:bool only; resolved via
settings_service.get_elevenlabs_api_key() (stored setting -> env), no restart.
New tts_available feature flag.
Dual-track migrations (SQLite + Alembic 0015/0016). New send_voice_reply MCP tool
(voice.ts) + POST /api/agents/{name}/voice-reply + voice_reply_service. Capability
advertised in the platform prompt only when voice is enabled for the agent and the
current channel's flag is on. OSS-core.
Related to trinity-enterprise#117
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(voice): forward source_channel* through the db facade create_task_execution (trinity-enterprise#117)
DatabaseManager.create_task_execution (the `db` facade wrapper) didn't forward the
new source_channel / source_channel_chat_id / source_channel_thread kwargs to
ScheduleOperations, so a channel-triggered task raised
"unexpected keyword argument 'source_channel'" → 500. Add the passthrough.
Related to trinity-enterprise#117
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(voice): allow send_voice_reply on channel turns via --allowedTools (trinity-enterprise#117)
Channel turns run headless with a restricted `--allowedTools` (default
WebSearch,WebFetch), which blocks every MCP tool — so an agent could not call
send_voice_reply even when the capability was advertised in its prompt. When the
voice capability is advertised for a channel turn, also append
`mcp__trinity__send_voice_reply` to the channel allowed-tools list so the agent
can actually act on it.
Related to trinity-enterprise#117
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(migrations): stage the Alembic re-chain — 0017/0018 revision ids + down_revision onto 0016_agent_ownership_ephemeral
The dev-merge commit renamed the files but the id/down_revision edits were left unstaged, leaving two heads off 0015 (pg-migrations 'Multiple head revisions' failure).
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Eugene Vyborov <eugene@beingluminous.com>
* chore(dev-skills): bump .claude — /cso v1.1 Trinity-shape refresh
Pointer bump to trinity-dev 2bdd362: fixes stale audit checks (setup-token
removal ent#49, Redis ACL model, dual-track DB / two-network stack facts)
and adds checks for post-skill surfaces (agent-key self-boundaries
#307/#1083/#918, backend→agent auth #1159, webhook HMAC ent#77, vendored
parity Invariant #5, enumeration uniformity #186, MCP description leak
#846, backlog_metadata G-04 class, enterprise-docs-guard ent#45).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(security): add CSO full-codebase audit report (2026-07-13)
Full-codebase Chief Security Officer audit (all phases, daily 8/10 gate).
No CRITICAL or exploitable-HIGH findings; prior CRITICAL (unauthenticated
agent-server, #1159) and both HIGH supply-chain items resolved. Ceiling is
MEDIUM: shared-user privilege tier, npm-ci lockfile bypass, unenforced
CODEOWNERS, plaintext backlog_metadata, unpinned deps, enterprise-doc
disclosure the guard can't see, python-multipart CVE. Two candidate findings
(orb.js XSS, Slack code takeover) downgraded to LOW by adversarial verifiers.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(portal): hide the platform Help widget on client-portal routes (#1588)
The global "Trinity Help" chat widget is fixed at bottom-right (z-50) and
overlapped the client portal chat composer's Send button. It's an operator/
platform-docs widget (gated on platform auth) and is meaningless on the
standalone client portal, so gate it off there via a `hideHelpWidget` route
meta on the two portal routes (`ClientPortalPublic` /portal and
`EnterpriseClientPortal`). App.vue now renders it only when
`authStore.isAuthenticated && !route.meta.hideHelpWidget`.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(portal): attach/upload files directly in the customer portal chat (ent#144) (#1587)
A client can now attach files in the portal chat composer. The backend already
had everything — `POST /client-portal/agents/{name}/documents` (writes to the
client inbox), and `portal_chat`'s `_collect_inbox_for_turn` attaches inbox
images as vision + lists documents — and the store already had
`uploadDocument`/`fetchUploads`. The only missing piece was the composer UI.
- `PortalChat.vue`: adds an `uploadDocument` prop and a paperclip attach control
(hidden multi-file input). Picked files upload to the inbox immediately and
render as chips with state (uploading / done / error) and a remove button.
On send, the done attachments' (server-sanitized) filenames are appended as
`[Attached: …]` so `_collect_inbox_for_turn` attaches images as vision THAT
turn (it keys on the filename / image-intent) and lists documents. Files-only
turns are allowed; send is blocked while an upload is in flight; a 25 MB
client guard mirrors the backend cap (415/quota surfaced as a chip error).
- `Portal.vue`: passes `:upload-document="(name, file) => store.uploadDocument(name, file)"`.
- The attach control hides when no upload handler is wired (operator-preview
`ClientPortal.vue` renderer), so nothing changes there.
Public-repo (gated Vue) change only — no backend/submodule change (reuses the
shipped enterprise endpoint + inbox consumption).
Refs Abilityai/trinity-enterprise#144
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(access): restore the per-recipient proactive-messaging toggle (#1577) (#1590)
The `allow_proactive` toggle (the only UI for #321/#376) was silently dropped
when the #1317 Access-tab redesign replaced the Sharing tab's Team Sharing rows
— leaving no way to opt a recipient into proactive messages except a raw API
call. Backend was fully intact; this is a UI-only restore.
- `AccessPanel.vue`: each operator row gets a Proactive toggle bound to the
`allow_proactive` the `/access` roster already returns (no extra fetch). On
change it persists via the store and reflects the server's confirmed value,
reverting + surfacing the error on failure (honest status, no optimistic-only
flip). Pending invites are toggleable too (the flag rides on the
`agent_sharing` row, which exists pre-resolution) with a tooltip on timing. A
static note states the owner is always allowed (owners aren't in the roster,
so there's no misleading owner toggle).
- `stores/agents.js`: new `setProactive(name, email, allow)` →
`PUT /api/agents/{name}/shares/proactive`.
- `feature-flows/proactive-messaging.md`: updated — it still documented the
removed SharingPanel markup/line numbers.
- `tests/unit/test_1577_proactive_toggle_guard.py`: static regression guard (3)
asserting the toggle + endpoint wiring survive future panel refactors (the
frontend has no JS unit runner; a full render e2e is a `ui` follow-up).
Related to #1577
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(github): wire the managed agent PAT for the gh CLI + REST API, not just git (#1574) (#1591)
Trinity injected the resolved GitHub PAT as GITHUB_PAT (authenticating git via
the origin URL) but not the `gh` CLI or REST API, which read GH_TOKEN/GITHUB_TOKEN
— so agents had to prefix every command with `GH_TOKEN="$GITHUB_PAT" gh …`, and
`gh` wasn't even installed. This makes the SAME token cover both. No new token,
endpoint, or UI — wiring only.
Expose GH_TOKEN + GITHUB_TOKEN = the resolved PAT at every point GITHUB_PAT is
set today, gated identically (only when a repo + PAT resolve — never an empty
token that makes `gh` look logged-in but broken):
- create (`crud.py`) and recreate (`lifecycle.py`) bake them into the container env;
- the no-restart `.env` propagation (`github_pat_propagation_service._patch_env_github_pat`)
now patches/adds all three keys, kept in sync;
- `startup.sh` exports them from GITHUB_PAT so child processes (agent server,
terminal shells) auto-authenticate — also covering older baked images that
only carry GITHUB_PAT, without a recreate.
Install the `gh` CLI in the agent base image (`Dockerfile`) from GitHub's official
apt repo (base image must be rebuilt for existing agents to get the binary; the
env vars are harmless on older images — git keeps working).
Honest surfaces: the `set_agent_github_pat` MCP tool description and
`docs/.../github-pat-setup.md` now state the managed token covers `gh`/REST too,
keeping the scope caveat (wiring makes the token available; it can't grant scopes
the token lacks). The credential sanitizer's `.*TOKEN.*`/`GITHUB_.*` patterns
already mask GH_TOKEN/GITHUB_TOKEN (asserted).
Tests: `tests/unit/test_1574_gh_token_wiring.py` — the .env patcher mirrors the
PAT onto all three keys (replace-in-place / append, lookalike-key-safe, value
mirrored); static guards that create/recreate/startup/Dockerfile each wire the gh
vars; sanitizer covers the new vars.
Related to #1574
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(operator-queue): quarantine persistently-failing creates to stop the sync hot-loop (#1525) (#1589)
The `created_at` KeyError in `create_item` was already made defensive on dev
(#1426 — `.get(...) or utc_now_iso()` + `on_conflict_do_nothing`). This closes
the remaining #1525 gap: the sync loop still re-attempted ANY failing create on
every ~5s cycle forever (the row never persists → `operator_queue_item_exists`
stays False → retry + ERROR-log indefinitely), so a DB error or any other
persistent create failure — not just the fixed field case — still hot-loops.
- `operator_queue_service._sync_agent`: track per-request consecutive
create-failure counts; after `MAX_CREATE_ATTEMPTS` (3) skip that request
(one WARN at the quarantine threshold instead of an unbounded ERROR stream).
A create that later succeeds clears the counter; an in-memory safety valve
caps the map so it can never grow without bound.
- `db/operator_queue.create_item`: the last hard-indexed field (`id`) now uses
`.get` and raises a clear `ValueError` (which the caller quarantines) instead
of an opaque `KeyError`. Also hardened the WS-broadcast `item["id"]`.
Tests: `test_1525_operator_queue_quarantine.py` (4, pure/mocked) — create is
attempted at most the cap (not once per cycle), success clears the counter, a
healthy request leaks no quarantine state, and the id-guard raises ValueError.
Related to #1525
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(mcp): one-click Copy connection config on the Expose-via-MCP panel (#1575) (#1585)
* feat(mcp): one-click Copy connection config on the Expose-via-MCP panel (#1575)
Surfaces a ready-to-paste external-client config — with a least-privilege,
agent-scoped, revocable API key already embedded — from the #846 exposure
panel, so an external MCP client connects in one flow (enable → copy → paste).
Direction: reuse the existing per-agent MCP connector (ent#46 → OSS #118)
rather than mint a duplicate credential system. The connector already provides
exactly the hard parts #1575 asks for — a scoped `scope='connector'` key,
owner-selected playbooks exposed as tools, and `build_snippets` producing
per-client `.mcp.json`/CLI blocks with the key embedded — but only on the
Sharing tab. This wires that capability onto `McpExposedPanel.vue` (Settings →
Expose via MCP), shown when `mcp_exposed` is on.
- Frontend-only, no new backend endpoint/key type: reuses
`GET/POST/DELETE /api/agents/{name}/connector[/key]` (owner-only,
`OwnedAgentByName`) + `ExposedToolsPanel` for the playbook allow-list.
- One-click "Copy connection config": mints (or regenerates) the scoped key and
copies the `.mcp.json` in a single action via the robust `utils/clipboard`
helper — the only moment the live secret exists.
- Copy-once integrity: with an existing key, "Copy config" copies the
placeholder config and offers "Regenerate & copy" for a fresh live key;
"Revoke" severs any connected client. Key already lists in Settings → MCP Keys.
- Safe default: the section only appears once the agent is MCP-exposed; a plain
warning marks the config as a live secret.
Docs: mcp-connector.md gains the 2nd-surface row; architecture #846 block notes
the connect surface.
Verification: `McpExposedPanel.vue` compiles clean via @vue/compiler-sfc
(full `vite build` blocked by a pre-existing unrelated missing `mermaid` dep in
AgentWorkspace.vue). Live click-through / ui-labeled e2e needs the running
frontend+backend stack (not up locally).
Not changed (documented scope): a 422 on an invalid playbook name — the picker
only offers live user_invocable playbooks, so the invalid path is UI-unreachable;
left to the connector's existing read-time filter.
Related to #1575
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(mcp): flash a "Copied!" animation on the connection-config copy buttons (#1575)
Adds an inline copied-state affordance to the connect-config copy actions
(Copy connection config / Copy config / per-client snippet Copy): on a
successful clipboard write the button swaps to a green "✓ Copied!" with a
scale-pop checkmark for ~1.6s, then reverts. One shared timer (cleared on
unmount); only fires when the copy actually succeeded; honors
prefers-reduced-motion.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(mcp): make the copied state transform the button itself (#1575)
Stronger, unmissable copy feedback: on a successful copy the primary
"Copy connection config" button fills solid success-green, gains a
ring + glow, bumps to a bolder "Copied to clipboard!" with a larger
checkmark, and plays a one-shot pop + outward ring-pulse
(copied-btn-flash) before settling. The existing-key "Copy config"
button gets the same solid-green ring treatment. Reverts after ~1.6s;
honors prefers-reduced-motion.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(mcp): animate the connector snippet Copy buttons too (#1575)
The MCP connector panel (Sharing tab, ConnectorChannelPanel) is the other
surface that shows per-client copy snippets; its plain "Copy" links gave no
feedback. Give them the same copied-state treatment as the exposure panel:
on a successful copy the button flashes solid success-green with a ring +
pop + "Copied!" checkmark, reverting after ~1.6s (honors reduced-motion).
Also route its copy() through the robust utils/clipboard helper instead of
raw navigator.clipboard.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(tests): conftest no longer deletes real agents named test-* (#1558) (#1586)
* fix(tests): stop conftest from deleting real agents named test-* (#1558)
The session-scoped `api_client` fixture deleted EVERY agent whose name began
with `test-` on whatever `TRINITY_API_URL` pointed at — silent, unprompted
data loss that destroyed a developer's real `test-agent-2` (bound Telegram
bot, chat history, GitHub sync). Any `test-*` agent on a staging/prod instance
was one test invocation from deletion.
Fix — fail-closed on every axis:
- Dedicated prefix: the suite now names every agent it creates
`pytest-ephemeral-*` (a namespace no human uses), never the broad `test-`.
Renamed all conftest agent generators (test_agent_name, module_agent_name,
stopped_agent, shared_agent).
- Session registry: created names are registered (`register_created_agent`)
and reclaimed by name at session end (belt over each fixture's own teardown).
- Startup leftover-sweep is now OPT-IN (`TRINITY_TEST_CLEANUP_SWEEP`), refuses
any non-localhost target (`is_local_target`), and only removes provably
suite-owned names (`select_sweepable_agents` — pure, fail-closed). Default:
no sweep at all.
- `cleanup_test_agent(require_suite_owned=True)` refuses (no stop/delete call)
a name the session can't prove it created — used by every sweep path.
- Docstring + tests/README now state plainly that the suite mutates the target
instance and must point at localhost.
…
AndriiPasternak31
added a commit
that referenced
this pull request
Jul 28, 2026
…talog
One template whose `credentials:` — or `credentials.mcp_servers` — was a list,
a string, or **null** raised an uncaught AttributeError out of
`_build_local_template` -> `get_local_templates()` -> `GET /api/templates`:
HTTP 500 with ZERO templates listed. One bad template hid every good one.
The `github:` builder was worse: no `isinstance` guard at all on untrusted repo
metadata (`_fetch_template_yaml` returns `safe_load(...) or {}`, and a top-level
list is truthy). Deeply nested YAML escaped the parse handler entirely as
`RecursionError` — a `RuntimeError`, not a `yaml.YAMLError`.
Worse than the crash because it was silent: `env_file: "OPENAI_API_KEY"` — an
ordinary typo, the list dash forgotten — was iterated character by character
into the generated `.env`, emitting fifteen single-letter variables, never
writing the real credential, with no error, no warning and no crash. The agent
booted and its MCP server failed at first use with nothing pointing at the
cause.
And `credentials.config_files[].path` was joined onto the staging directory and
opened for write with no normalization, so an absolute path or a `..` escape
was an arbitrary-file-write primitive — reachable by any authenticated user,
since `deploy_local_agent_logic` accepts an uploaded template archive.
Four tolerant readers in `template_service.py` are now the only way into the
block, with two deliberately opposite contracts:
* Read paths never raise. The catalog degrades the derived field to empty,
attaches `credential_errors` to the entry, logs one WARNING naming the
template id — and the template STILL LISTS. `get_local_templates` also fences
each per-template build, so a future unguarded field cannot regress the
property the readers buy.
* The write path fails loud. `generate_credential_files` validates first and
raises the HTTP-free `CredentialDeclarationError`, which its only caller maps
1:1 to 400 `INVALID_CREDENTIAL_DECLARATION` (Invariant #1: services hold no
HTTP concerns). It is called before the docker try/except, so the 400 is not
flattened to a 500.
`config_files[].path` is rejected at the parse boundary AND re-checked at the
write sink (`crud._safe_cred_file_path` -> 400 `INVALID_CREDENTIAL_FILE_PATH`),
using the same resolve + `is_relative_to` CodeQL barrier as
`_safe_local_template_path`.
Absent / null / `{}` all stay a valid zero-credential contract — the ent#124
starter trio ships exactly that, and a commented-out block must not acquire a
spurious warning.
`credentials.env_file` stays a names-only list. The enriched per-variable
declaration lands under its own top-level key (PR-B) precisely so an older
Trinity reading a newer template is structurally untouched.
Tests: `test_ent128a_catalog_resilience.py` — 33 pass here, 31 fail on
`origin/dev`, the two headline ones with the exact bug signatures
(`AttributeError: 'list' object has no attribute 'get'` and the literal
`O=\nP=\nE=\nN=` output). Includes a parity test so a malformed BUNDLED template
fails CI instead of a user's fresh install.
No DB change, so the dual-track migration rule (#9) does not apply.
PR-A of trinity-enterprise#128 (AC #5). PR-B — the enriched declaration schema —
re-gates after this merges.
Refs Abilityai/trinity-enterprise#128
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
vybe
pushed a commit
that referenced
this pull request
Jul 29, 2026
…1804) (#1863) * docs(requirements): CAS-won terminal owns the paired activity close (#1804) Trinity Rule #1 — requirements before implementation. scheduling.md §10.15: the terminal-write contract now includes closing the paired agent_activities dispatch row. Records the one-owner helper, the activity CAS lattice (mirrors the execution predicate), the widened lookup, the bulk/per-row split by cardinality, and the terminal-write-anchored parity guard. infrastructure.md §12.9 (CLEANUP-001): the 120-minute activity sweep is demoted to a backstop for the unclaimed, and moves after the stale-slot reaper so it can no longer beat a legitimate closer within one cycle. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(activities): make the activity close a lattice CAS with a tri-state outcome (#1804) T1+T2. The db-layer spine for the #1804 contract. - ActivityCloseOutcome (UPDATED / ALREADY_CLOSED / NOT_FOUND). One bool cannot answer both "did this row exist" (routers/internal.py 404s) and "did anything change" (activity_service broadcasts); once idempotent no-op closes are a designed outcome the two answers diverge routinely. - complete_activity becomes an atomic CAS on _close_predicate, which MIRRORS the execution-row predicate in db/schedules/executions.py: incoming COMPLETED -> activity_state != 'cancelled' (an authoritative close may upgrade a provisional FAILED — the #1083 late-SUCCESS-after-lease-expiry path); incoming CANCELLED/FAILED -> activity_state = 'started' (nothing overwrites an authoritative close, so a double close cannot clobber completed_at / duration_ms / error). The lattice diagram is inline over the predicate. - get_open_activity_id_for_execution takes include_failed so the LOOKUP agrees with the write — a lookup narrower than the CAS makes the widened predicate inert. Ordering prefers still-'started' rows. - close_open_activities_for_executions: set-wise bulk close for the watchdog sweeps, one transaction, no per-row WS, chunked at _SQLITE_MAX_IN_VARS. No schema change, no migration (Rule #9 not triggered). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(activities): add close_execution_activity, the single owner of the close contract (#1804) T3. A leaf helper on activity_service (imports only models/database/db.activities — no services.* edge, so no cycle) that every CAS-won terminal writer calls. - Reuses models.activity_state_for_terminal (#1332) — no second mapping. - Lattice-aware lookup: an authoritative terminal (SUCCESS/CANCELLED) searches started|failed so it can upgrade a provisional FAILED; a provisional terminal searches started only. Callers pass a terminal status and stay ignorant of it. - Delegates to complete_activity, so the agent_activity WebSocket broadcast and subscriber notify survive (what a db-layer close would have lost). - Broadcasts only on ActivityCloseOutcome.UPDATED; ALREADY_CLOSED is a designed no-op and must not emit an event claiming the activity just closed. - complete_activity's bool now means "exists and closed" — only NOT_FOUND is False, so routers/internal.py keeps its 404 semantics and an idempotent re-close does not start 404ing the scheduler. - spawn_close_execution_activity: sync fire-and-forget wrapper for the synchronous pull sink, mirroring spawn_task_terminal_event (strong task ref, fail-open with no running loop). Fail-open throughout: the close runs after a committed terminal write. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(execution): close the paired activity at every CAS-won terminal writer (#1804) T4-T8. Wires the eight terminal writers onto the single owner. task_execution_service: - _write_terminal_and_gate gains the missing CAS-LOSS branch (the SUCCESS applier has had one since #1332): re-read the row and close the activity in the terminal that actually stands. `activity_status` leaves the signature — it is derived from `status`, so the two can no longer drift. - the asyncio.CancelledError shutdown handler now closes too. #767 closed the execution record here so the sweep would not inflate ITS duration but left the activity for the 120-minute backstop to inflate instead; worse, the row is `failed`, so startup recovery (which scans `running`) skips it forever. routers/internal: the second shutdown writer, same shape (no activity_id in scope — the helper looks it up). cleanup_service: - watchdog and startup _recover_execution close on the CAS-won branch. The startup one was DISCARDING its CAS bool (returned True unconditionally absent an exception); it is now captured, gates the close, and is returned. - _close_bulk_swept_activities: a SIBLING of _emit_bulk_terminal_events, never folded into it — that method short-circuits when nobody subscribes, which would skip the close on every install with no event subscribers. Driven by the collect_failed rows #1714 already collects; one transaction, no per-row WS. - _sweep_stale_activities moves AFTER _sweep_stale_slots: it ran one line before the reaper that legitimately closes activities, so within a single cycle the 120-minute fabricator could beat a real closer. - _close_reaped_activity / _close_stale_slot_activity delegate to the helper. - CleanupReport.activities_closed_on_recovery (not summed into `total` — it is an observability counter over work already counted). pull_coordination_service: closes on the CAS-won branch via the sync spawn wrapper. lease_reaper_service: requeued_execution_ids, closed CANCELLED — a superseded attempt is not a failure, and the re-queue preserves execution_id so the next delivery opens a second activity against the same row. chat_execution_service: terminate folds onto the helper (it was the fifth hand-rolled copy of the idiom, and a copy is invisible to the parity guard). Behaviour unchanged — R4. db/schedules/cleanup: mark_no_session_executions_failed returns the CAS-WON count, not the candidate count (its sibling already did). report. no_session_executions was over-reporting; deliberate, visible value change. Tests updated where a mock encoded the old call surface; one behavioural assertion inverted on purpose (test_terminal_write_cas_gate: a lost CAS now closes the activity in the persisted state instead of leaving it open). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(1804): R1-R5 regressions, the two-table chain test, and the parity guard T9+T10. test_1804_recovery_closes_activity.py — db layer against the real schema (db_harness) + service + every wired writer: R1 a FAILED activity upgrades to COMPLETED, through the lookup (the pairing that keeps the fix from being inert) R3 an already-closed close never touches completed_at/duration_ms/error R2 _write_terminal_and_gate's CAS-loss closes with the PERSISTED state R5 both backend-shutdown CancelledError handlers close their activity plus: broadcast on UPDATED only, watchdog/startup gating on the CAS bool, the bulk close running with zero event subscribers (the #1714 gate scopes the event, never the close), the pull sink on applied-but-not-replayed/ conflict, and the lease reaper's requeued_execution_ids. test_1804_terminal_activity_chain.py — the two CAS predicates live in two different tables; mocks encode what the author believed, so these run the real statements and assert the tables agree. Includes the late-SUCCESS upgrade end to end and the cancel-is-never-overwritten mirror image. test_1804_terminal_activity_parity.py — anchored on terminal WRITES, not on completion-event emission. The emit set is a strict subset (both shutdown writers emit nothing), which is exactly how they hid from review. Explicit allowlist, each entry justified by lifecycle position, plus a self-test that the guard actually fires and a staleness check on the allowlist. R4 (terminate byte-identical) lives in test_1332, which the previous commit updated to the new call surface. tests/lint_sys_modules.py: clean, no new violations. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(architecture): record the terminal-activity close contract (#1804) New canonical block "Terminal-Activity Close Contract (#1804)" — one home for the feature, pointers from the places that used to imply the old model: - Fire-and-Forget Dispatch (#1083): the close is a property of winning the CAS, not of holding the activity_id local; apply_result is one writer among eight. - Task Completion Events (#1578): the emit set is NOT the close set — both shutdown handlers write a terminal and emit nothing, which is why the parity guard is anchored on terminal writes. - Background Services / Cleanup Service: the recovery closes, activities_closed_on_recovery, and the backstop moving last in the cycle. - services catalog: activity_service owns the closer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(feature-flows): the terminal-activity close contract (#1804) /sync-feature-flows. activity-stream.md — new "The Close Contract (#1804)" section: the owner (close_execution_activity + the sync spawn wrapper), the state lattice with the side-by-side execution/activity diagram and why the lookup must agree with the CAS, the split by cardinality, the full writer table, and the demotion of the 120-minute backstop. task-execution-service.md — the _write_terminal_and_gate lost-CAS branch (and the dropped activity_status param), the backend-shutdown close, and a row in the Activity Tracking table pointing at the contract. dashboard-timeline-view.md — why the amber bar used to lie, and that no frontend change was needed: ReplayTimeline was reading a lying database. feature-flows.md — Recent Updates row. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(1804): make the close outcome identity-stable and never downgrade a recovery Two real defects the full-suite run surfaced (both green in isolation, red at 5,100 tests — the pollution class the dossier warned about). 1. ActivityCloseOutcome moves db/activities.py -> models.py. Callers compare it by `is`, and db_harness evicts `db.activities` from sys.modules per test. Any test that imported services.activity_service BEFORE an eviction held one enum class while a later `from db.activities import ...` got a different one, so every identity check silently went False. models is the leaf everything imports and nothing re-imports, so the object stays the same one — and it now sits beside ActivityState/TaskExecutionStatus, where a shared db↔service contract type belongs. Re-exported through db.activities. 2. Both _recover_execution closes get their own try/except. The terminal write and the capacity release have already succeeded at that point; letting a close failure propagate flipped a recovered row into the `errors` bucket and would make the watchdog retry a row it had already recovered. Fail-open is the rule for everything after a committed terminal — this was the one place I left it implicit. Full unit suite: 5136 passed, 14 skipped, 0 failed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(1804): the #1083 result callback must look up failed activities too Self-review catch. The callback endpoint IS the late-SUCCESS path — the one the lattice was widened for. A lease reaper that beat the callback has already FAILED the row and closed the activity FAILED; the execution CAS deliberately lets a genuine late SUCCESS correct the row ("a FAILED row falls through so a late SUCCESS can still overwrite a reaper LEASE_EXPIRED"). With a started-only lookup the callback passed activity_id=None, apply_result closed nothing, and the pair settled at execution=success, activity=failed — permanently. That is #1804 inverted, on the exact path the upgrade exists for. Harmless for a FAILED callback: the close CAS refuses to overwrite an already-closed activity. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(activity-stream): note the callback's include_failed lookup (#1804) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test: confine the git-reset loader's sys.modules stubs to their own test `_load_git_service` installs Mocks under the real names `database`, `services.docker_service` and `services.activity_service`. It deliberately leaves them in `sys.modules` after the loader returns (the reset path imports `activity_service` lazily at call time) — but nothing put them back afterwards either, so the Mocks stayed installed for the rest of the session. That is a silent, ordering-dependent landmine for any later test that lazily imports one of those names. #1804 makes `cleanup_service._close_stale_slot_activity` delegate to `activity_service.close_execution_activity`; with this file running first, `test_1083_lease_reaper.py::test_swallows_errors` awaited the leaked Mock and died with `TypeError: object Mock can't be used in 'await' expression` — a real red under CI's pytest-randomly seeds, in a test unrelated to git reset. Adds the lint-recognised `_STUBBED_MODULE_NAMES` + `_restore_sys_modules` autouse pair, also restoring `services.git_service*` (the loader force-reimports it against the Mocks, so leaving that behind poisons later importers too). `tests/lint_sys_modules.py` drops this file from 9 violations to 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(1804): close the reflexive COMPLETED edge and make CAS-loss labels honest Three review findings on the terminal-activity close contract. 1. The close lattice inherited the upstream's permissive edge (the bug it exists to prevent). `_close_predicate` was specified as an authority ORDERING (`started < failed < {completed, cancelled}`) but implemented as an EXCLUSION copied literally from the execution CAS: `!= 'cancelled'`. Those two disagree exactly on the reflexive case — `!= 'cancelled'` matches `'completed'`, so a second COMPLETED close was permitted and re-dated `completed_at`/`duration_ms`/`error`. Executed against the real path, a 15-minute activity (`duration_ms=900000`) re-closed COMPLETED became ~9h: the exact fabricated-duration symptom #1804 exists to eliminate, arriving through #1804's own fix. It is reachable from `_write_terminal_and_gate`'s lost-CAS branch, which passes an explicit `activity_id` — so the narrower `started|failed` lookup cannot shield it. The literal copy was faithful: `update_execution_status`' SUCCESS predicate really is `!= CANCELLED` and really does admit `success -> success`. The execution row survives that edge only because an upstream guard (the #1083 callback's authoritative-terminal replay short-circuit) stops the duplicate before it reaches the CAS — a protection the activity's new call sites do not have. The predicate now states the ordering literally, `IN ('started','failed')`, and the branch's five lattice tests gain the `X -> X` case they all skipped because it looks like a no-op. 2. A CAS loss to a *successful* row stamped "superseded by ..." onto a clean success, and rendered the enum repr while doing it. `error` is now None on an authoritative SUCCESS close (a non-NULL `error` on a `completed` activity reads as a problem in every activity-derived view — the rule the shipped terminate path already follows), and the label uses `.value`, since `f"{TaskExecutionStatus.FAILED}"` renders as `TaskExecutionStatus.FAILED` on 3.11+ — the `str, Enum` footgun #1578 already paid for. 3. `_recover_execution` returning the terminal CAS bool (it must, to gate the close) changed what a False MEANS to `recover_orphaned_executions`, which counted every False as `errors`. A lost CAS is RELIABILITY-005's guarded writer working as designed — a real completion landed during restart — so folding it into `errors` makes a healthy restart race read as a failing one, and would have made this fix look like it introduced failures. False is now partitioned: genuine exceptions self-count into `stats["errors"]`, the remainder is reported as `cas_lost`. Docs updated in lockstep (architecture.md, requirements/scheduling.md, feature-flows/activity-stream.md) to state the COMPLETED arm as deliberately tighter than the execution CAS rather than a literal mirror, plus a learnings.md entry on inheriting a permissive edge when mirroring a predicate. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Eugene Vyborov <eugene@beingluminous.com>
AndriiPasternak31
added a commit
that referenced
this pull request
Jul 29, 2026
…talog
One template whose `credentials:` — or `credentials.mcp_servers` — was a list,
a string, or **null** raised an uncaught AttributeError out of
`_build_local_template` -> `get_local_templates()` -> `GET /api/templates`:
HTTP 500 with ZERO templates listed. One bad template hid every good one.
The `github:` builder was worse: no `isinstance` guard at all on untrusted repo
metadata (`_fetch_template_yaml` returns `safe_load(...) or {}`, and a top-level
list is truthy). Deeply nested YAML escaped the parse handler entirely as
`RecursionError` — a `RuntimeError`, not a `yaml.YAMLError`.
Worse than the crash because it was silent: `env_file: "OPENAI_API_KEY"` — an
ordinary typo, the list dash forgotten — was iterated character by character
into the generated `.env`, emitting fifteen single-letter variables, never
writing the real credential, with no error, no warning and no crash. The agent
booted and its MCP server failed at first use with nothing pointing at the
cause.
And `credentials.config_files[].path` was joined onto the staging directory and
opened for write with no normalization, so an absolute path or a `..` escape
was an arbitrary-file-write primitive — reachable by any authenticated user,
since `deploy_local_agent_logic` accepts an uploaded template archive.
Four tolerant readers in `template_service.py` are now the only way into the
block, with two deliberately opposite contracts:
* Read paths never raise. The catalog degrades the derived field to empty,
attaches `credential_errors` to the entry, logs one WARNING naming the
template id — and the template STILL LISTS. `get_local_templates` also fences
each per-template build, so a future unguarded field cannot regress the
property the readers buy.
* The write path fails loud. `generate_credential_files` validates first and
raises the HTTP-free `CredentialDeclarationError`, which its only caller maps
1:1 to 400 `INVALID_CREDENTIAL_DECLARATION` (Invariant #1: services hold no
HTTP concerns). It is called before the docker try/except, so the 400 is not
flattened to a 500.
`config_files[].path` is rejected at the parse boundary AND re-checked at the
write sink (`crud._safe_cred_file_path` -> 400 `INVALID_CREDENTIAL_FILE_PATH`),
using the same resolve + `is_relative_to` CodeQL barrier as
`_safe_local_template_path`.
Absent / null / `{}` all stay a valid zero-credential contract — the ent#124
starter trio ships exactly that, and a commented-out block must not acquire a
spurious warning.
`credentials.env_file` stays a names-only list. The enriched per-variable
declaration lands under its own top-level key (PR-B) precisely so an older
Trinity reading a newer template is structurally untouched.
Tests: `test_ent128a_catalog_resilience.py` — 33 pass here, 31 fail on
`origin/dev`, the two headline ones with the exact bug signatures
(`AttributeError: 'list' object has no attribute 'get'` and the literal
`O=\nP=\nE=\nN=` output). Includes a parity test so a malformed BUNDLED template
fails CI instead of a user's fresh install.
No DB change, so the dual-track migration rule (#9) does not apply.
PR-A of trinity-enterprise#128 (AC #5). PR-B — the enriched declaration schema —
re-gates after this merges.
Refs Abilityai/trinity-enterprise#128
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
vybe
pushed a commit
that referenced
this pull request
Jul 30, 2026
…talog (ent#128 PR-A) (#1835) * fix(templates): stop a malformed `credentials:` block emptying the catalog One template whose `credentials:` — or `credentials.mcp_servers` — was a list, a string, or **null** raised an uncaught AttributeError out of `_build_local_template` -> `get_local_templates()` -> `GET /api/templates`: HTTP 500 with ZERO templates listed. One bad template hid every good one. The `github:` builder was worse: no `isinstance` guard at all on untrusted repo metadata (`_fetch_template_yaml` returns `safe_load(...) or {}`, and a top-level list is truthy). Deeply nested YAML escaped the parse handler entirely as `RecursionError` — a `RuntimeError`, not a `yaml.YAMLError`. Worse than the crash because it was silent: `env_file: "OPENAI_API_KEY"` — an ordinary typo, the list dash forgotten — was iterated character by character into the generated `.env`, emitting fifteen single-letter variables, never writing the real credential, with no error, no warning and no crash. The agent booted and its MCP server failed at first use with nothing pointing at the cause. And `credentials.config_files[].path` was joined onto the staging directory and opened for write with no normalization, so an absolute path or a `..` escape was an arbitrary-file-write primitive — reachable by any authenticated user, since `deploy_local_agent_logic` accepts an uploaded template archive. Four tolerant readers in `template_service.py` are now the only way into the block, with two deliberately opposite contracts: * Read paths never raise. The catalog degrades the derived field to empty, attaches `credential_errors` to the entry, logs one WARNING naming the template id — and the template STILL LISTS. `get_local_templates` also fences each per-template build, so a future unguarded field cannot regress the property the readers buy. * The write path fails loud. `generate_credential_files` validates first and raises the HTTP-free `CredentialDeclarationError`, which its only caller maps 1:1 to 400 `INVALID_CREDENTIAL_DECLARATION` (Invariant #1: services hold no HTTP concerns). It is called before the docker try/except, so the 400 is not flattened to a 500. `config_files[].path` is rejected at the parse boundary AND re-checked at the write sink (`crud._safe_cred_file_path` -> 400 `INVALID_CREDENTIAL_FILE_PATH`), using the same resolve + `is_relative_to` CodeQL barrier as `_safe_local_template_path`. Absent / null / `{}` all stay a valid zero-credential contract — the ent#124 starter trio ships exactly that, and a commented-out block must not acquire a spurious warning. `credentials.env_file` stays a names-only list. The enriched per-variable declaration lands under its own top-level key (PR-B) precisely so an older Trinity reading a newer template is structurally untouched. Tests: `test_ent128a_catalog_resilience.py` — 33 pass here, 31 fail on `origin/dev`, the two headline ones with the exact bug signatures (`AttributeError: 'list' object has no attribute 'get'` and the literal `O=\nP=\nE=\nN=` output). Includes a parity test so a malformed BUNDLED template fails CI instead of a user's fresh install. No DB change, so the dual-track migration rule (#9) does not apply. PR-A of trinity-enterprise#128 (AC #5). PR-B — the enriched declaration schema — re-gates after this merges. Refs Abilityai/trinity-enterprise#128 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(templates): make the credential-file path guard a two-step barrier CodeQL flagged `_safe_cred_file_path` with two HIGH `py/path-injection` alerts, on the join itself and on the containment check. It was right to: the helper had only step 2 of the pattern (resolve + `is_relative_to`), and CodeQL does not treat that alone as a barrier — the sibling `_safe_local_template_path` clears the same query because it allowlists the RAW string first. Add that step 1 here too: reject empty, absolute, `..`-bearing, and anything outside `[A-Za-z0-9._/-]` before the value ever reaches the join, then keep the resolve + containment as step 2. `_CRED_FILE_PATH_RE` permits `/` (unlike `_LOCAL_TEMPLATE_NAME_RE`) because this is a relative *path*, not a single slug. Not defensive padding for a scanner — the two-step shape is what makes the guard legible to a reader as well, and it is the established pattern in this file. Test extends to the allowlist rejections (empty, leading dash, space, semicolon) alongside the traversal cases. Refs Abilityai/trinity-enterprise#128 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.