Skip to content

Fix: Add missing Docker labels to system agent container - #1

Merged
vybe merged 1 commit into
mainfrom
fix/system-agent-port-labels
Dec 25, 2025
Merged

Fix: Add missing Docker labels to system agent container#1
vybe merged 1 commit into
mainfrom
fix/system-agent-port-labels

Conversation

@oleksandr-korin

Copy link
Copy Markdown
Contributor

The system agent was missing required Docker labels (trinity.ssh-port, trinity.cpu, trinity.memory, trinity.created), causing port allocation conflicts when creating new agents.

Without trinity.ssh-port label:

  • get_agent_status_from_container() returns port=0
  • get_next_available_port() filters out port 0
  • System agent's port (2290) appears available
  • New agents fail with 'port already allocated' error

This fix adds all required labels to match the standard agent creation pattern in routers/agents.py, ensuring proper port tracking and conflict prevention.

The system agent was missing required Docker labels (trinity.ssh-port,
trinity.cpu, trinity.memory, trinity.created), causing port allocation
conflicts when creating new agents.

Without trinity.ssh-port label:
- get_agent_status_from_container() returns port=0
- get_next_available_port() filters out port 0
- System agent's port (2290) appears available
- New agents fail with 'port already allocated' error

This fix adds all required labels to match the standard agent creation
pattern in routers/agents.py, ensuring proper port tracking and conflict
prevention.
@oleksandr-korin
oleksandr-korin requested a review from vybe December 24, 2025 13:33
@vybe
vybe merged commit 97ab834 into main Dec 25, 2025
vybe added a commit that referenced this pull request Dec 27, 2025
Bug #1: Terminal session lost when switching tabs
- Changed v-if to v-show for terminal tab content in AgentDetail.vue
- Keeps terminal component mounted, preserving WebSocket connection

Bug #2: MCP deploy_local_agent only copied CLAUDE.md
- Updated startup.sh to copy ALL template files instead of hardcoded list
- Now includes template.yaml and custom directories (src/, lib/, etc.)
- Added .trinity-initialized marker to prevent re-copying on restart

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
dolho added a commit that referenced this pull request Apr 23, 2026
Move the pre-check exec logic out of routers/internal.py into a
dedicated services/pre_check_service.py. Aligns with Invariant #1
(Router → Service → DB): the router becomes a 5-line passthrough,
all business logic (path constant, two exec calls, stdout capping,
contract dict) lives in the service.

Same shape as services/slot_service.py, services/task_execution_service.py,
services/monitoring_service.py — testable in isolation, discoverable
via grep, won't trip /validate-architecture later.

Behavior unchanged. 13/13 scheduler unit tests still pass; live
endpoint hit returns same contract dict.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
dolho added a commit that referenced this pull request Apr 24, 2026
Move the pre-check exec logic out of routers/internal.py into a
dedicated services/pre_check_service.py. Aligns with Invariant #1
(Router → Service → DB): the router becomes a 5-line passthrough,
all business logic (path constant, two exec calls, stdout capping,
contract dict) lives in the service.

Same shape as services/slot_service.py, services/task_execution_service.py,
services/monitoring_service.py — testable in isolation, discoverable
via grep, won't trip /validate-architecture later.

Behavior unchanged. 13/13 scheduler unit tests still pass; live
endpoint hit returns same contract dict.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
vybe pushed a commit that referenced this pull request Apr 27, 2026
… (#501)

Replaces raw `DELETE FROM agent_git_config` SQL in routers/git.py with the
existing `db.delete_git_config()` method (already used at line 435 of the same
file for the init-failure rollback path). Restores Architectural Invariant #1
(Three-Layer Backend) for this router. No behavior change — identical SQL,
identical parameter binding.

Closes #451

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
dolho added a commit that referenced this pull request Apr 27, 2026
Move the pre-check exec logic out of routers/internal.py into a
dedicated services/pre_check_service.py. Aligns with Invariant #1
(Router → Service → DB): the router becomes a 5-line passthrough,
all business logic (path constant, two exec calls, stdout capping,
contract dict) lives in the service.

Same shape as services/slot_service.py, services/task_execution_service.py,
services/monitoring_service.py — testable in isolation, discoverable
via grep, won't trip /validate-architecture later.

Behavior unchanged. 13/13 scheduler unit tests still pass; live
endpoint hit returns same contract dict.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
vybe pushed a commit that referenced this pull request Apr 28, 2026
* feat(scheduler): agent-owned pre-check hook (#454)

New optional contract: agents implement POST /api/pre-check in their
container; scheduler calls it before firing a cron-triggered chat.
Endpoint absent or any error → fire as usual (fail-open). fire=false
records a skipped execution. fire=true with a message overrides the
schedule.message for that invocation.

- docker/base-image/agent_server/routers/pre_check.py: new router that
  dynamically loads /home/developer/.trinity/pre-check.py (template-
  supplied) and calls its check() function
- agent-server main.py: mount pre_check_router
- scheduler/agent_client.py: pre_check() method with fail-open semantics
  on 404/5xx/timeout/malformed-response
- scheduler/service.py: _run_pre_check + pre-check branch in
  _execute_schedule_with_lock (cron only; manual triggers bypass)
- tests/scheduler_tests/test_pre_check.py: 12 tests covering client-
  and service-level behavior; 161/161 scheduler suite passes

Zero schema change — reuses existing ExecutionStatus.SKIPPED and
create_skipped_execution. Closes the "wake agent on every cron tick"
cost gap noted in docs/planning/PR_REVIEWER_AGENT.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(#454): scheduler pre-check feature flow + arch + requirements

- feature-flows/scheduler-pre-check.md: new flow doc with contract,
  fail-open semantics, error table, testing summary
- architecture.md: add /api/pre-check to agent-server endpoint list
  and pre-check note to Scheduler Service row
- requirements.md: SCHED-COND-001 entry under §10 (Scheduling & Execution)
- feature-flows.md: index row
- docs/planning/PR_REVIEWER_AGENT.md: design doc from which this
  feature was extracted — committed for traceability

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* review: address PR #455 review feedback

- pre_check.py: asyncio.get_event_loop() → get_running_loop() (deprecated in 3.10+)
- pre_check.py: oversized message override no longer dropped silently —
  response now carries message_truncated="override dropped: N bytes exceeds
  32000 cap" so scheduler/operator can see what happened; log escalated to
  ERROR with size+limit details
- pre_check.py: module-level docstring expanded to note the security scope
  of check() (full Python interpreter access, same sandbox as chat tools —
  operators should review .trinity/pre-check.py like any executable template
  file) and the intentional no-cache behavior
- tests/unit/test_pre_check_router.py: 15 new router/unit tests covering
  oversized-message drop path and non-dict return → 500 (both previously
  only exercised by inspection). Uses importlib to load pre_check.py
  directly, avoiding python-multipart requirement from sibling routers
- feature-flows/scheduler-pre-check.md: document truncation behavior,
  security scope expectation, and updated test summary (12 scheduler +
  15 router = 176 total passing)

Lock-scope concern noted in review is not an issue: the skip path returns
from _execute_schedule_with_lock, and the outer _execute_schedule holds
the lock in a try/finally that covers the return. No leak.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(#454): docker exec instead of agent-server HTTP endpoint

Review feedback on #455 flagged that the HTTP-endpoint design introduced
a new system edge (scheduler → agent-server direct) and a novel code-
loading pattern (importlib in a router). Both broke with Trinity's
established convention that all "run something in an agent container"
flows go through `services/docker_service.execute_command_in_container`
— the same primitive used by:

- services/git_service.py (persistent-state allowlist, #384 S3)
- services/ssh_service.py (key provisioning)
- services/agent_service/terminal.py (web SSH)
- routers/system_agent.py (admin exec)
- adapters/message_router.py (Slack file ingest)
- routers/voice.py, monitoring_service.py

This commit swaps the design accordingly.

Changes:
- Delete docker/base-image/agent_server/routers/pre_check.py and its
  router registration. No new HTTP surface on agent-server.
- Delete tests/unit/test_pre_check_router.py (router is gone).
- Add src/backend/routers/internal.py →
  POST /api/internal/agents/{name}/pre-check. Runs the template-shipped
  `.trinity/pre-check.py` via execute_command_in_container. Two-step:
  `test -f` for existence, then `python3 .../pre-check.py`. Returns
  {hook_present, exit_code, stdout, stderr}. Gated by existing
  X-Internal-Secret header (C-003).
- Rewrite src/scheduler/service.py::_run_pre_check to call the backend
  endpoint (scheduler no longer opens a direct edge to agent-server).
  Translates backend response to the same {fire, message, reason}
  shape so _execute_schedule_with_lock is unchanged.
- Delete AgentClient.pre_check from src/scheduler/agent_client.py.
- Rewrite tests/scheduler_tests/test_pre_check.py to mock the backend
  HTTP (httpx.AsyncClient) instead of the agent HTTP client. 13 tests
  covering translation: hook absent → None, non-zero exit → None,
  empty stdout → skip, non-empty stdout → fire with override, 404,
  5xx, connection error, malformed JSON.
- Update docs/memory/architecture.md §Agent Containers and §Background
  Services, docs/memory/requirements.md SCHED-COND-001, and
  docs/memory/feature-flows/scheduler-pre-check.md to reflect the new
  topology. feature-flows.md index updated.

Template side (dolho/pr-reviewer-agent commit 4330d2a): .trinity/
pre-check.py rewritten as a standalone shebanged script that prints
the chat prompt to stdout or exits 0 empty.

Benefits:
- Preserves "scheduler → backend → agent" topology (Invariant #11).
- Uses Trinity's dominant pattern for agent-container command execution.
- No importlib, no module caching, no pydantic contract — exit code +
  stdout is unambiguous.
- Smaller code footprint: internal endpoint is ~50 lines, scheduler
  translation is ~50 lines, template script is ~50 lines.

Test coverage: 162/162 scheduler suite passing (up from 161 — new case
for malformed backend JSON).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(#454): make pre-check hook language-agnostic

Drop the `python3` invocation from the backend's pre-check exec and
rename the convention path from `~/.trinity/pre-check.py` to
`~/.trinity/pre-check`. Trinity now execs the file directly — the
interpreter is selected by the file's shebang line. Templates can ship
Python, bash, node, or a compiled binary; Trinity stops caring about
language.

`test -f` (not `-x`) is kept for the existence check so a
present-but-non-executable file surfaces as an exec failure (exit 126)
in the operator log instead of silently falling through to the
backward-compat "no hook" path.

Docs (architecture / feature-flow / requirements / index) updated to
reflect the new contract. Scheduler-side translation tests are
unaffected — they assert the JSON contract, not the exec command
string. 13/13 tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(#454): extract pre_check_service from internal router

Move the pre-check exec logic out of routers/internal.py into a
dedicated services/pre_check_service.py. Aligns with Invariant #1
(Router → Service → DB): the router becomes a 5-line passthrough,
all business logic (path constant, two exec calls, stdout capping,
contract dict) lives in the service.

Same shape as services/slot_service.py, services/task_execution_service.py,
services/monitoring_service.py — testable in isolation, discoverable
via grep, won't trip /validate-architecture later.

Behavior unchanged. 13/13 scheduler unit tests still pass; live
endpoint hit returns same contract dict.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
vybe added a commit that referenced this pull request Apr 30, 2026
* feat(webhooks): agent schedule webhook triggers (WEBHOOK-001, #291)

Add public webhook URLs so external systems (CI/CD, CRMs, monitoring) can
trigger agent schedule executions via a simple HTTP POST with no Trinity
account required — authenticated by a 256-bit opaque token embedded in the URL.

Changes:
- New public router POST /api/webhooks/{token}: rate-limited (10/60s per
  token), audit-logged, 202 Accepted, delegates to existing scheduler trigger
- JWT-auth CRUD: POST/GET/DELETE /api/agents/{name}/schedules/{id}/webhook
- DB migration: webhook_token (TEXT UNIQUE), webhook_enabled (INTEGER DEFAULT 0)
  on agent_schedules; partial unique index for O(1) token lookup
- Scheduler updated to accept triggered_by param in JSON body so executions
  record triggered_by="webhook" correctly
- Webhook context field framed as data to reduce prompt injection surface
- 12 integration tests in tests/test_webhook_triggers.py

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(security): patch 4 Dependabot alerts — happy-dom + vite (#486)

Bumps two dev-only dependencies to patched versions. Production is
unaffected (happy-dom is test-only; vite only runs in local dev).

- src/frontend: vite ^6.0.6 → ^6.4.2 (closes Dependabot #55, CVE-2026-39363)
- tests/git-sync: happy-dom ^15.11.7 → ^20.9.0 (closes #83/#84/#85:
  VM context escape RCE, ESM code exec, fetch cookie leakage)

Verified: frontend `vite build` clean, all 10 git-sync vitest tests pass
under happy-dom 20.9.0. No new critical/high alerts introduced.

Closes #485

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(webhooks): add WEBHOOK-001 to requirements and architecture (fixes #484 review)

Add missing documentation for the webhook trigger feature:
- requirements.md: WEBHOOK-001 entry with description, key features, DB changes,
  API endpoints, security model, and feature flow link
- architecture.md: webhooks.py listed in Routers table; Schedules table expanded
  from 9 to 12 endpoints with the 3 webhook management endpoints; new Webhook
  Triggers section documenting the public POST /api/webhooks/{token} endpoint;
  webhook_token and webhook_enabled columns added to agent_schedules schema block

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(#488): add status-in-dev label + PR-merge automation (#489)

Close the gap between "PR merged to dev" and "released to main".

- New GH Action `issue-status-on-merge.yml`: on PR merge to dev,
  parse Fixes/Closes/Resolves #N from PR body+title, add
  `status-in-dev`, remove `status-in-progress`.
- `/release` skill: read `gh issue list --label status-in-dev` as
  the authoritative shipping list for release notes; include
  `Closes #N` in the release PR body so issues auto-close on merge
  to main.
- `DEVELOPMENT_WORKFLOW.md`: SDLC is now Todo → In Progress →
  In Dev → Done, each stage mapped 1:1 to commit-graph location.

Fixes #488

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(channels): file upload Phase 2 — workspace delivery hardening (#487) (#494)

Phase 2 of #354 polishes the shared channel-agnostic file delivery path
in `message_router._handle_file_uploads`. Phase 1 (#355) added Telegram
extraction/download/validation; the actual workspace write path was
introduced for Slack inbound (#222). This change hardens the shared path
for both channels:

- New `_sanitize_filename` helper: NFKC unicode normalize → basename →
  safe-chars regex → empty/dotfile fallback to `file_{id}` → 200-char
  truncation preserving extension → collision dedup with `-1`, `-2`, …
- Spec injection format: `[File uploaded by {uploader}]: {name} ({size})
  saved to {path}`. Uploader is the verified email when present
  (Issue #311), else `adapter.get_source_identifier(message)`.
- All-writes-failed handling: when every workspace write attempt fails,
  the router replies on the channel with an explicit error and skips
  agent execution (#487 AC6). Validation rejections (size/MIME/download
  errors) still surface in the description block as before.
- Audit log entries gain an `uploader` field.

Per-session upload directory (`/home/developer/uploads/{session_id}/`)
preserved — keeps user uploads isolated and ephemeral, matches the
existing #222 model.

Tests: +17 unit tests across `TestFilenameSanitization` (12),
`TestFileDeliveryFormat` (2), `TestFileDeliveryFailures` (3). 28/28
passing in `tests/unit/test_file_upload.py`.

Docs: `telegram-integration.md` Phase 2 section + revision row;
`slack-file-sharing.md` flow / router / errors / security sections
updated for the shared change; `feature-flows.md` index row.

Closes #487

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(webhooks): import Request in schedules router (#495)

The WEBHOOK-001 commits (c630931 / 8fdf736) added `request: Request`
parameters to `generate_webhook` and `get_webhook_status` without
importing `Request` from fastapi. Backend module import fails with
NameError on startup, blocking all dev deploys.

Integration tests in tests/test_webhook_triggers.py exercise these
endpoints but never caught the bug because the backend never starts —
test setup fails before any test runs.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(backlog): repair drain spawn — lazy-import target after #95 (#496) (#500)

services/backlog_service.py:240 lazy-imported _execute_task_background
from routers.chat, but #95 deleted that function. Every backlog drain
attempt failed with ImportError; the exception was swallowed at
backlog_service.py:218-228, so BACKLOG-001 (#260) was silently dead.
Live observation: 23 drain failures / 24h on a fan-out workload, only
surface signal was the per-execution `error` column.

Why it shipped silently: the unit happy-path test patched
sys.modules["routers.chat"] with a SimpleNamespace stub of whatever
attribute name it expected, masking the production breakage.

Changes:
- Lazy-import _run_async_task_with_persistence (the post-#95
  replacement) and adjust the call shape (drop release_slot, drop
  orphaned task_activity_id; the unified executor handles both).
- Capture self-task fields (is_self_task, self_task_activity_id,
  inject_result) at enqueue time and rehydrate on drain so
  SELF-EXEC-001 (#264) survives backlog overflow.
- Emit a stable log token `backlog_drain_spawn_failed` so log-based
  detection (Vector / dashboards) can catch import drift or similar
  spawn-time regressions at fleet scale rather than per-row.
- AST-based regression guard in tests/unit/test_backlog.py:
  TestLazyImportTarget parses routers/chat.py and asserts the import
  target exists; paired test asserts the lazy-import string matches
  the validated allow-list. Catches both directions of drift without
  booting the backend.
- Update happy-path test to use the new symbol and kwarg surface;
  add self-task enqueue+drain round-trip tests.

Closes #496

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(announce): add Twitter/X support via API v2 + OAuth 1.0a

Bumps announce skill to v1.6. Adds a Python helper (scripts/post_twitter.py)
that reads tweet text from stdin and posts via Twitter API v2 using OAuth 1.0a
User Context — same exit-0/1 + structured-JSON contract as the existing
Discord/Slack/Telegram send paths so the sequential-only and no-blind-retry
rules apply uniformly. Credentials live in .env (gitignored) under
ANNOUNCE_TWITTER_* keys.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(chat): sync /task long-polls on backlog at capacity (#498) (#515)

Sync parallel `/task` calls (parallel=true, async=false) at capacity used
to fail terminally with HTTP 429 — they never touched the BACKLOG-001
backlog because the spill block was nested under `if request.async_mode:`.
Observed in production: ~40% terminal-failure rate from one MCP fan-out
caller (214 capacity rejections / 24h, 0 enqueues from 541 dispatches).

Sync calls now spill to the same backlog the async path uses and long-poll
on the open HTTP connection until the queued execution reaches a terminal
status, then return the result inline. True 429 only when the backlog is
also full. Total connection hold capped at 2 × effective_timeout.

Implementation:
- New `services/sync_waiter.py` owns the in-process registry and the
  `signal_sync_waiter` / `wait_for_sync_terminal` primitives. Wait combines
  an asyncio.Future (set by the drain finally block) with a 5s DB-poll
  fallback that covers terminal flips routed outside the drain
  (corrupt-metadata, expire_stale, cleanup recovery).
- `routers/chat.py` sync branch now mirrors the async branch:
  pre-acquires the slot, on at-capacity calls `backlog.enqueue()` then
  `wait_for_sync_terminal()`, returns the inline result on wake.
- `_run_async_task_with_persistence` wraps its body in try/finally and
  signals any registered sync waiter with the rich TaskExecutionResult
  plus chat_session_id. No-op when no waiter is registered (the common
  async fire-and-forget path).

Tests (`tests/unit/test_chat_sync_backlog.py`, 13 new):
- Signal / wait / poll-fallback / timeout / cleanup / concurrent waiters
- Regression test pins TERMINAL_TASK_STATUSES to the enum so a new
  TaskExecutionStatus value forces a deliberate update (caught a missing
  SKIPPED entry pre-merge)

Trade-off (Policy B): worst-case connection hold doubles to
2 × effective_timeout when the request is queued. Honest envelope —
the caller chose to wait. Documented in the architecture diagram of
`persistent-task-backlog.md`.

Companion issue #505 covers the orchestration-education gap (MCP tool
description + platform prompt) so agents pick the right tool for the
job rather than relying on the platform absorbing every misuse.

Closes #498

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(groom): document SDLC stages and add status-label/board reconciliation

Adds SDLC context (Todo → In Progress → In Dev → Done) so grooming respects
in-flight work, and a Step 1b that reconciles status-* labels with board
columns (labels are authoritative).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(agent): classify signal-killed claude exits as 504, not fake auth failure (#517)

External signal terminations of the claude subprocess (timeout SIGKILL,
OOM-kill, parent SIGTERM, operator cancel) used to fall through to the
auth-fallback heuristics and surface as a misleading "Subscription token
may be expired" 503. Same shape as #361 (max-turns), different exit path.

Adds _classify_signal_exit() consulted before the auth heuristics: matches
Python-native signal exits (return_code < 0) and shell-encoded forms
(130/137/143 for SIGINT/SIGKILL/SIGTERM) and raises HTTP 504 with a clear
"killed by SIGKILL/SIGTERM/SIGINT — likely timeout, OOM, or operator
cancel" message. Tightens the zero-token heuristic with return_code > 0
so signal exits cannot reach it.

The bug became routinely reproducible after #61 (PR #326) added
backend-driven terminate_execution_on_agent() — every timeout now
produces a signal-killed claude subprocess on the agent side, which the
old heuristic block misclassified. Also de-risks PR #508 (auth-class
auto-switch): without this fix, every timeout would trigger an
unnecessary subscription rotation.

Backend's task_execution_service.py only flags AUTH on 503; 504 falls
through to the generic FAILED path. No backend changes required.

Closes #516

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(sprint): align skill with DEVELOPMENT_WORKFLOW.md (#519)

Four divergences between the /sprint playbook and the SDLC documented in
docs/DEVELOPMENT_WORKFLOW.md:

- Step 3 used `gh issue edit --add-label status-in-progress` directly,
  bypassing .github/workflows/claim.yml and skipping self-assignment.
  Now posts `/claim` as an issue comment, which is the workflow's single
  source of truth for the In Progress transition.
- Step 8 invoked pytest directly via `cd tests && source .venv/bin/activate
  && python -m pytest …`. Now defers to `/test-runner [feature]`, with a
  documented fallback for brand-new files outside the runner's catalog.
- Step 10 commit + PR body used `closes #N`. Workflow §1 specifies
  `Fixes #N`; both auto-close on GitHub but the doc is the contract.
- Step 11 final report didn't mention the post-merge automation. Now
  warns that issue-status-on-merge.yml owns the
  status-in-progress → status-in-dev transition, so operators don't
  manually edit labels post-merge.

Non-breaking: argument signature, automation level (gated), state
dependencies, and pipeline overview unchanged. Net +19/-11.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(agent): classify clean-exit empty-result as 502, not silent success (#520) (#521)

* fix(agent): classify clean-exit empty-result as 502, not silent success (#520)

Sibling of #516/#517 on the return_code == 0 path. When the claude
subprocess exits 0 but the final {"type":"result"} JSON line is dropped
before the reader thread captures it (typical cause: a child subprocess
inherited stdout, kept the pipe open past claude exit, the reader thread
leaked, the pgroup unwind closed the pipe), metadata.cost_usd and
metadata.duration_ms stay None. The success path used to return HTTP 200
anyway — agent-server logged "completed successfully" while backend
silently reaped the execution as an orphan minutes later, masking the
real failure with a misleading "completed on agent but recovered by
watchdog" message.

Adds _classify_empty_result(metadata, raw_message_count) consulted after
the return_code != 0 block (#516 + auth heuristics) and before response
building. When both cost_usd and duration_ms are None, raises HTTP 502
with diagnostic context (tools, turns, raw_messages, cause hint).
Backend's task_execution_service.py:542 only flags AUTH on 503, so 502
falls through to the generic FAILED path with the helpful detail
preserved — no backend changes needed.

The two-field check is conservative: single-field nullability could be a
Claude format quirk; both-None is a strong signal that the terminal
result message never arrived. Test coverage pins the scope so a future
edit can't silently broaden it.

Changes:
- docker/base-image/agent_server/services/claude_code.py — new
  _classify_empty_result() helper next to _classify_signal_exit; call
  site between the return_code != 0 block and response building.
- tests/unit/test_empty_result_classification.py — 9 new tests, all
  pass. Covers both-None → 502, populated metadata → None,
  single-field-only → None (Claude format quirk tolerance), zero-cost
  and zero-duration → None (is None vs falsy), missing metadata → None.
- docs/memory/feature-flows/parallel-headless-execution.md — changelog
  entry under Recent Updates.
- docs/memory/feature-flows/task-execution-service.md — row in
  error-translation table + new Empty-Result Pre-Check paragraph.

Requires base-image rebuild after merge:
./scripts/deploy/build-base-image.sh

Closes #520

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(feature-flows): index entry for agent error classification (#516, #520)

Combined Recent Updates entry covering the matching pair of agent-side
error-classification fixes that shipped this week — _classify_signal_exit
(#516, PR #517) and _classify_empty_result (#520, PR #521). Both touch
docker/base-image/agent_server/services/claude_code.py and share the
"agent surfaces the right HTTP status so backend records FAILED with a
useful detail" theme.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(agent): off-load synchronous terminate cleanup off the event loop (#523)

The async terminate_execution endpoint and the outer asyncio.TimeoutError
handlers in execute_claude_code and execute_headless_task were calling
registry.terminate() / _terminate_process_group() / _safe_close_pipes()
synchronously. Those helpers do up to 7s of process.wait() (SIGINT grace
+ SIGKILL grace), which blocks the asyncio event loop for the entire
window. While blocked, agent-server cannot serve /health, the backend
circuit breaker opens, and UI fan-out hangs for 5+ minutes per page.

This is the actual user-visible mechanism behind the #523 "agent-server
wedge" symptom, not the FD-inheritance / leaked-reader-thread theory in
the original report (see issue comment for the corrected diagnosis —
the FD_CLOEXEC fix as written would not have helped because dup2 strips
CLOEXEC during the child's stdout setup, and the existing post-#407
killpg + safe_close path actually works in the vast majority of cases).

Wrap the three call sites in loop.run_in_executor(None, ...) so the
blocking process.wait() runs on a thread-pool worker. Pipe-inheritance
fragility remains a slow-burn cleanup item to be filed separately.

- routers/chat.py: terminate_execution dispatches registry.terminate to
  the default executor
- services/claude_code.py: outer-timeout cleanup in both async paths
  off-loads _terminate_process_group + _safe_close_pipes
- tests/unit/test_terminate_async_executor.py: regression test asserts
  registry.terminate runs on a non-event-loop thread and the event-loop
  yield stays sub-50ms while terminate is in flight

Fixes #523

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(planning): add Tier 2.6 hardening + actor-model destination roadmap

Records the architectural critique from 2026-04-26 review:
- Tier 2.6 (Sprint D′): #524 state machine contract, #525 idempotency
  keys, #526 dispatch circuit breaker. Closes the three contract-level
  gaps that survive even after Sprint D's plumbing consolidation.
- Future considerations: 7 unranked recommendations (durable
  ProcessRegistry, retry-in-funnel, synchronous terminate ack, dual
  streams, fairness, EventBus backpressure, lifecycle contract doc).
- Target architecture section: names the actor model as the destination
  (mailbox + journal + processor), maps existing components to the
  concepts they already implement, defines a 4-phase gated transition
  roadmap, and gates Phase 2 (agent-to-agent experiment) on a one-page
  message-envelope + journal-format postcard.

Issues: #524, #525, #526 created and added to project board (Epic
#411 Orchestration Invariants, Theme Reliability).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(planning): mark #291 (WEBHOOK-001) shipped, Sprint C now 5/5

#291 closed 2026-04-24, shipped via PR #484 (token-in-URL trigger
through TaskExecutionService) with follow-up fix PR #493. The plan
doc still listed it as the next item to pick up; align it with the
ground truth and re-aim "what to do next" at #428 (after #306 soak)
plus Tier 2.6 hardening (#524/#525/#526) in parallel.

* refactor(capacity): consolidate three queue/slot primitives into CapacityManager (#428) (#527)

* refactor(capacity): consolidate ExecutionQueue + SlotService + BacklogService into CapacityManager (#428)

Single public facade for agent execution capacity. Composes SlotService
(Redis ZSET counter) and BacklogService (SQL persistent overflow) as
private internals; owns the in-memory overflow store (Redis LIST, depth 3,
lifted from the deleted ExecutionQueue).

Why:
- 7 caller sites now go through one API instead of orchestrating three.
- Each new trigger type (retry, webhook, self-exec, fan-out) gets one path
  for capacity, not a choice between three primitives.
- Unblocks #429 (CLEANUP-COLLAPSE) and the actor-model destination by
  reducing the surface a single capacity store has to expose.

API:
  capacity.acquire(agent, exec_id, max_concurrent, *,
                   overflow_policy='reject'|'queue_in_memory'|'queue_persistent',
                   overflow_payload=PersistentTaskPayload(...))
  capacity.release(agent, exec_id)        # idempotent
  capacity.release_if_matches(agent, eid) # TOCTOU-safe (watchdog)
  capacity.get_status(agent, max_concurrent)
  capacity.reclaim_stale(agent_timeouts)  # called by cleanup_service
  capacity.force_release(agent)           # emergency
  capacity.cancel_all_overflow(agent, reason)  # agent deletion
  capacity.run_maintenance(max_age_hours)  # 60s tick from main.py

Wire format unchanged: same Redis keys (agent:slots:*, agent:queue:*),
same SQL columns (schedule_executions.queued_at, backlog_metadata).
In-flight executions unaffected; clean revert path.

Deviations from issue spec (user-approved):
- No feature flag — single runtime path. dev-soak + clean revert is the
  rollback mechanism, simpler than a per-agent DB column + flag check at
  every call site.
- ExecutionQueue deleted in this PR rather than separate cleanup PR.
  SlotService and BacklogService kept as private internals (well-factored,
  one job each).

Soak deviation: shipped after 5 days of #306 soak rather than the planned
14 days. Mitigated by additive-style refactor (no wire-format change).

Files:
- NEW services/capacity_manager.py (~480 LOC)
- DELETE services/execution_queue.py (~360 LOC)
- 7 caller migrations: routers/chat.py (4 sites), routers/agents.py (2),
  routers/agent_config.py (1), services/cleanup_service.py (4),
  services/task_execution_service.py (1), services/agent_service/queue.py (3),
  main.py (1, callback wiring is now internal).
- NEW tests/unit/test_capacity_manager.py — 21 tests covering acquire/release
  for all three overflow policies, drain wiring, status, force_release,
  reclaim_stale, cancel_all_overflow.
- UPDATE tests/test_watchdog_unit.py — 11 mock decorator pairs collapsed to
  single get_capacity_manager mock.

Tests: 21 new + 35 watchdog + 33 backlog = 89 green for affected surface.

Fixes #428

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(feature-flows): add capacity-management.md, deprecate predecessor flows (#428)

- NEW capacity-management.md — public surface, overflow policies, end-to-end
  /chat and /task flows, storage map, maintenance & recovery, what-replaced-what.
- DEPRECATE notes on the three predecessor flows with redirects:
  - execution-queue.md (ExecutionQueue deleted)
  - parallel-capacity.md (SlotService internalized)
  - persistent-task-backlog.md (BacklogService internalized)
- "Now uses CapacityManager" notes on four downstream flows:
  - task-execution-service.md, parallel-headless-execution.md,
    cleanup-service.md, execution-termination.md
- Index: Recent Updates row + Core Agent Features row for capacity-management.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(requirements): note BACKLOG-001 is now internal to CapacityManager (#428)

Section 10.8 (Persistent Task Backlog) — replace direct SlotService callback
reference with the unified CapacityManager facade. Status bumped with the
2026-04-26 internalization date and #428 cross-ref.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(agent): drain pipe before close to preserve final result line (#531) (#532)

* fix(agent): drain pipe before close to preserve final result line (#531)

drain_reader_threads previously called safe_close_pipes() immediately
after terminate_process_group(), discarding the kernel pipe buffer before
the reader thread could drain it. On long agentic tasks the final
{"type":"result"} JSON line (cost, duration, answer) was in that buffer
at the moment of close, causing the reader to raise ValueError and
metadata.cost_usd / duration_ms to remain None — triggering the HTTP 502
"Execution completed without a result message" classification from #521.

Fix: reorder so grandchildren are killed first, then the reader is given
post_kill_grace=30s to drain naturally (grandchildren dead → kernel
delivers EOF once the buffer is consumed → reader returns '' and exits
cleanly). safe_close_pipes() is now a true last resort — only called when
the reader is still alive after 30s, which indicates a genuine wedge, not
unfinished backlog drain.

Also extends _classify_empty_result to derive num_turns from raw_messages
when metadata.num_turns is None (result line lost), so the 502 detail
reports an honest turn count instead of always showing 0.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(tests): update test catalog for #531 drain_reader_threads fix

- Add test_subprocess_pgroup.py and test_empty_result_classification.py
  to Test Categories (Operations & Observability, unit section)
- Add 2026-04-27 Recent Test Additions entry with description of the
  pipe-drain ordering regression tests and raw_messages fallback tests
- Update unit test count: 165 → 170; total: 2,257 → 2,262

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(feature-flows): document drain_reader_threads pipe-ordering fix (#531)

Update parallel-headless-execution.md with the root cause fix for
the "Execution completed without a result message" HTTP 502: the old
drain_reader_threads sequence closed the pipe before the reader could
drain the kernel buffer (including the final result JSON line). New
sequence: kill grandchildren → natural drain (post_kill_grace=30s) →
force-close only as last resort.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(tests): update orphaned-recovery mocks to CapacityManager (#533) (#534)

Replace stale services.slot_service sys-mock with services.capacity_manager
so all four recovery scenario tests pass after the #428 consolidation.
Assertions updated from release_slot → release to match the new API.

Fixes #533

Co-authored-by: Claude <noreply@anthropic.com>

* docs(skills): align validate-pr with DEVELOPMENT_WORKFLOW.md

Add quick triage block, base branch check, PR size warning, type-docs
label, Base Branch/PR Size rows in report table, and review pipeline
matrix linking /review and /cso --diff with their complementary roles.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(validate-architecture): stale-citation filter + dedupe guard (#511) (#513)

* fix(validate-architecture): add stale-citation filter + issue dedupe guard (#511)

The /validate-architecture skill produced false-positive issue #479 by:
1. citing file paths the report's snapshot saw, but `main` no longer has
   (process engine deleted in #430 the same day);
2. running `gh issue create` with no check for existing open issues with
   the same finding fingerprint.

Two targeted edits to .claude/skills/validate-architecture/SKILL.md:

- New Step 2c "Filter Stale Citations" — `git ls-files --error-unmatch`
  every cited path before report. Drop ghosts. Downgrade FAIL → PASS
  when an invariant has zero remaining real citations.

- Modified Step 4 — fingerprint = sorted invariant numbers; query
  open `automated,priority-p1` issues with `--search "in:body
  validate-architecture fingerprint=<fp>"`; comment on existing
  issue instead of creating duplicate. Issue body now stamps the
  current commit SHA for evidence binding.

Closes #511.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(validate-architecture): clarify dedupe branching, distinct fingerprint marker, quote paths (#511)

Follow-up to review feedback on PR #513. Three small skill-prose
hardenings:

- I2 (LLM-driven flow control): the dedupe branch previously relied on
  `if [ -n "$EXISTING" ]; then ...; exit 0; fi` followed by a separate
  create block. `exit 0` halts a bash subshell, not an LLM walking the
  markdown — a future runner could execute both blocks. Replace with
  explicit "Path A — COMMENT, then STOP" / "Path B — CREATE" prose
  branching and an explicit DO-NOT note.

- I4 (fingerprint collision): replace free-text body search
  `validate-architecture fingerprint=$FP` with HTML-comment marker
  `<!-- validate-architecture::fingerprint=$FP -->` plus a quoted-phrase
  search. Self-evidently programmatic; won't collide with prose.

- I3 (path quoting): the Step 2c example now uses `"$path"` and a note
  about shell metachars, so implementers don't strip the quotes.

- Add concurrency caveat documenting that the dedupe is best-effort,
  not atomic (no GitHub primitive provides this).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(config): clean stale Auth0 / AUDIT_URL, document SMTP/SendGrid/FRONTEND_URL (#481) (#509)

- Remove dead Auth0 env vars + build args from docker-compose.prod.yml
  and docker/frontend/Dockerfile.prod (Auth0 removed 2026-01-01). The
  build-arg fallbacks were also leaking a real Auth0 domain + client ID
  into a public repo.
- Drop AUDIT_URL from .env.example (audit-logger service no longer exists;
  no Python references it).
- Add FRONTEND_URL to .env.example (required in prod for OAuth post-auth
  redirects in slack_service.py / public_links.py and SSH host
  auto-detection in ssh_service.py).
- Document SMTP_HOST/PORT/USER/PASSWORD and SENDGRID_API_KEY in
  .env.example so the advertised EMAIL_PROVIDER=smtp/sendgrid modes are
  actually configurable from the template.

Closes #481

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(auth): require auth on /api/docs endpoints (#452) (#507)

Add Depends(get_current_user) to the three handlers in
src/backend/routers/docs.py so the file no longer violates
Architectural Invariant #8.

Note: this router is not currently registered in main.py, so
the endpoints are not reachable on the running API. The fix is
applied to the file as written so the invariant validator stops
flagging it and so the file is correct if it is ever remounted.

Closes #452

* fix(chat): wrap long unbroken strings in chat bubbles (#457) (#502)

Long URLs, tokens, base64 blobs, and other unbroken strings in agent
chat responses were overflowing their 85% bubble and forcing horizontal
scroll on the entire Chat tab.

Root cause: ChatBubble.vue capped the bubble width but never told
inner content how to handle unbreakable strings. Inline <code> and
the user-text <p> had no overflow-wrap; <pre> defaulted to white-space:
pre with no overflow-x: auto override.

Fix (CSS-only, all 3 render branches — user / self-task / assistant):
- min-w-0 on outer wrapper, overflow-hidden on inner bubble
- break-words on user text and prose container
- prose-pre:overflow-x-auto + prose-pre:max-w-full so code blocks
  scroll inside the bubble instead of expanding it
- prose-code:break-words for long inline tokens
- prose-a:break-words for long URLs in markdown links

Verified visually: before/after static test page shows BEFORE leaks
content well past the bubble border; AFTER wraps cleanly with no
regression on normal markdown (headings, lists, links, short code).

* fix(schedules): add missing Request import for webhook endpoints (#493)

Regression from c630931 (WEBHOOK-001 / #291): `routers/schedules.py`
uses `Request` as a type annotation on the `generate_webhook` and
`trigger_webhook` handlers but never imports it, so the module fails
to load and the backend won't start with a NameError.

Minimal fix: add `Request` to the existing `from fastapi import …`
line (line 11). No behavioral change — the annotation was already
intended.

Surfaced while dev-testing FILES-001 (PR #491). uvicorn reload pulled
in the dev branch state and blew up.

Co-authored-by:  Pavlo <pash@pashs-MacBook-Pro.local>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(git): route orphan cleanup through db.delete_git_config (#451) (#501)

Replaces raw `DELETE FROM agent_git_config` SQL in routers/git.py with the
existing `db.delete_git_config()` method (already used at line 435 of the same
file for the init-failure rollback path). Restores Architectural Invariant #1
(Three-Layer Backend) for this router. No behavior change — identical SQL,
identical parameter binding.

Closes #451

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(subscription): auto-switch on first failure + auth-class triggers (#441) (#508)

Drop the 2-consecutive-429 gate in `subscription_auto_switch` so a single
subscription failure now triggers a switch — the 2h skip-list on
alternative selection (already pinned by #444 / #476 regression tests) is
sufficient as the lone thrash guard. Broaden the trigger surface to also
fire on auth-class failures (401/403/credit balance/expired OAuth token,
etc.), classified via a centralized `AUTH_INDICATORS` list, so a broken
subscription auto-recovers instead of failing every execution until
manual intervention. Flip the `auto_switch_subscriptions` default to
"true" — operators can still opt out, but the safe behavior is now the
default. Backward-compat shim `handle_rate_limit_error` preserved for
existing 429 callers.

- services/subscription_auto_switch.py: new `handle_subscription_failure`
  with `failure_kind` dispatch ("rate_limit" | "auth"); new
  `is_auth_failure` classifier; default flipped; notification + log
  wording adapts per kind; old shim retained.
- services/task_execution_service.py: 503 / auth-classified errors now
  also call the switch path alongside 429.
- routers/chat.py (sync): same broadening on the interactive chat
  surface; auth path returns 503+retry hint mirroring the 429 UX.
- routers/subscriptions.py: GET `/auto-switch` default also flipped to
  "true" so the UI toggle and runtime gate read the same value.
- scheduler/service.py: dedupe two inline `auth_indicators` copies into
  a single module-level constant; cross-reference the canonical list in
  backend (cross-container import not viable).
- tests/unit/test_subscription_auto_switch_pingpong.py: new
  TestIsAuthFailure + TestSingleEventThreshold classes (8 new tests, all
  pingpong + #476 aging tests still green).
- tests/test_subscription_auto_switch.py: flip default-off → default-on.
- docs: SUB-003 feature flow + requirements doc reflect the new
  threshold, broadened scope, and on-by-default behavior.

Closes #441

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(feature-flows): clean up #95 drift missed by #500 (#496) (#503)

* fix(backlog): repair drain spawn after #95 rename (#496)

`services/backlog_service.py:_spawn_drain` was lazy-importing
`_execute_task_background` from `routers.chat`, but #95 (PR #316) deleted
that function and replaced it with `_run_async_task_with_persistence`.
Every backlog drain raised `ImportError`, was caught at line 218-228, and
silently marked queued executions FAILED — leaving BACKLOG-001 (#260)
non-functional whenever an agent hit capacity.

Rewire the lazy import to the new helper and adjust the call shape:
- drop `task_activity_id` (not in new signature; chat router already
  passes None at enqueue)
- drop `release_slot=True` (the wrapper passes `slot_already_held=True`
  to TaskExecutionService, which manages release in its finally block)
- derive `is_self_task` from x_source_agent vs agent_name
- pass `self_task_activity_id=None` (queued items don't carry one;
  separate gap, not in scope here)

Add `tests/test_backlog_drain_unit.py` with five regression checks:
two AST-based contract tests that pin the function name and signature
in `routers/chat.py` (would have caught the original break), and three
runtime spy tests covering the kwarg shape `_spawn_drain` forwards. The
existing `tests/unit/test_backlog.py::test_drain_happy_path_spawns_background`
is updated to match the new contract.

Sync the BACKLOG-001 and TaskExecutionService feature-flow docs to
reference the renamed helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(feature-flows): sync index + parallel-capacity for #496

- Add #496 entry to feature-flows.md Recent Updates.
- Fix two more stale `release_slot=True` references in
  parallel-capacity.md left over from #95 — the param never existed
  on `_run_async_task_with_persistence` (slot release happens inside
  TaskExecutionService via slot_already_held=True).

Other stale `release_slot=True` references in
authenticated-chat-tab.md and parallel-headless-execution.md are
deeper drift (separate flows, not touched by #496) — leave for a
follow-up doc-cleanup pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(catalog): register test_backlog_drain_unit.py (#496)

Adds the new BACKLOG-001 regression test file to tests/registry.json
so it shows up in the catalog alongside test_event_bus.py and
unit/test_backlog.py.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: drop redundant test_backlog_drain_unit.py

PR #500 (which superseded the original #496 fix scope) shipped
equivalent contract coverage with a more robust setup:
- `TestLazyImportTarget` (AST guard for the lazy-import target)
- `test_drain_threads_self_task_fields` (round-trip via real
  BacklogService against sqlite)

The local file used sys.modules stubs which were strictly weaker.
Keeping it would only add maintenance burden for duplicate coverage,
so drop the file and its registry entry. Net effect on PR #503 is
that it becomes a small, focused docs-cleanup PR (parallel-capacity.md
and task-execution-service.md drift from #95, plus the missing
Recent Updates entry for #496).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(deploy): align scripts, configs, and docs with production operating patterns (#504)

* docs(generate-user-docs): add hub-and-spokes deployment structure and ops-pattern import

Restructure skill to produce guides/deploying/ as a hub plus six spokes
(local-development, single-server, public-access, upgrading,
backup-and-restore, monitoring) instead of one flat deploy guide.

Add an "operational guide" template (When to Run → Pre-flight →
Procedure → Verify → Rollback) for procedural docs that don't fit the
feature-shaped dual-audience template, plus verbatim-reuse snippets for
the load-bearing rules: never down/up, rebuild platform services only,
six-probe verification, resource-thresholds table, alpine cp backup.

Add Step 2h to draw operational patterns from the private ops runbook
under ../trinity-ops/, with explicit safe/forbidden import lists and a
sshpass→localhost rewrite rule.

Strengthen Step 2e to cross-check .env.example keys against each
compose's environment block — docs must not promise behavior the
chosen compose can't deliver.

Add public-safety greps in Step 7 (sshpass, trinity-ops, tailnet, real
IPs, instance-dir refs) so leaked private detail blocks completion.

Tracks issue #504.

* fix(deploy): align scripts, configs, and docs with production operating patterns (#504)

Fixes the first-run blocker (agent creation fails silently without base
image) and removes references to the removed audit-logger service that
caused verify-platform.sh and validate.sh to always fail.

Scripts:
- start.sh: detect missing base image and auto-build on first run; use
  `docker compose stop` in help text (not `down`, which destroys agents)
- verify-platform.sh: full rewrite — remove trinity-audit-logger and port
  8001 audit checks; fix frontend from port 3000 → 80; check scheduler
  health at :8001; add MCP/Vector probes; fix login hint
- validate.sh: remove non-existent `deployment/` dir, `QUICK_START.md`,
  and `src/audit-logger/audit_logger.py` from required paths; fix port 3000

Config:
- docker-compose.yml: wire 5 missing env vars into backend (PUBLIC_CHAT_URL,
  FRONTEND_URL, EXTRA_CORS_ORIGINS, SLACK_SIGNING_SECRET, SSH_HOST)
- .env.example: remove stale AUDIT_URL; annotate prod-only / overlay-only
  vars (SLACK_SIGNING_SECRET, PUBLIC_CHAT_URL, FRONTEND_URL, SSH_HOST,
  TRINITY_GIT_BASE_URL) so users know scope before setting them

Docs:
- deploying-trinity.md: add explicit build-base-image.sh step; fix
  /trinity:connect to use MCP API key flow (not username/password); add
  Upgrading, Health Verification, Resource Thresholds, and Common Recovery
  Patterns sections from ops runbook; use `docker compose` (v2 syntax)
- setup.md: remove false claim that start.sh builds the base image; correct
  admin account creation (env var driven, not wizard); clarify wizard path
  (used only when ADMIN_PASSWORD is unset)

New file:
- quickstart.sh: interactive one-command setup (checks Docker, generates
  secrets, sets ADMIN_PASSWORD, builds base image, starts services, verifies)

Skill:
- generate-user-docs: add deployment config reading rules (read scripts
  literally; cross-check env vars vs compose; never claim "auto" unless code
  proves it); add operational guide template (pre-flight/steps/verify/rollback);
  resolve conflict preserving the hub+spokes guide structure from branch

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(generate-user-docs): remove private repo name from SKILL.md

Replace explicit `trinity-ops` repo references with generic path aliases
(`../ops-runbook/`) so the private repo name is not embedded in this
public repository.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(reliability): CAS guards on execution status writes + state machine doc (#524) (#541)

Closes the FAILED→SUCCESS and SUCCESS→FAILED races that were patched by
#378 re-verify logic without eliminating the root cause.

Changes:
- update_execution_status: SUCCESS writes are unconditional (agent wins);
  non-success terminal writes blocked when row already terminal
- mark_stale_executions_failed / mark_no_session_executions_failed: inner
  UPDATE gains AND status='running' to close the SELECT→UPDATE TOCTOU window
- _recover_execution: routes through mark_execution_failed_by_watchdog
  (already CAS-guarded) instead of bare update_execution_status
- TaskExecutionStatus: state machine, transitions, and authorized writers
  documented in docstring; PENDING_RETRY added to enum
- Remove now-dead _STALE_SLOT_ERROR_PATTERN constant

Full projector architecture (ExecutionStateProjector, agent event emission,
projected_status shadow column) deferred — agents have no Redis access and
the restart-recovery design needs more thought before those land.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(public-chat): build context before storing user message to prevent duplication (#539) (#540)

* fix(public-chat): build context before storing user message to prevent duplication (#539)

In the public chat endpoint, the user message was persisted to the database
before build_public_chat_context read from it, causing the current message
to appear twice in every agent prompt — once in "Previous conversation:"
and once in "Current message:". Reordering the calls so context is built
first (from prior history only) then the user message is stored eliminates
the duplicate on every turn.

Adds unit tests that document both the old broken order (two occurrences)
and the corrected order (one occurrence), guarding against regression.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(feature-flows): update public-agent-links with #539 context ordering fix

- Correct PUB-005 data flow: build_public_chat_context before add_public_chat_message
- Update backend implementation step ordering to match fixed code
- Add revision history entry for the bug fix
- Add #539 entry to feature-flows.md index

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(agent-runtime): guard content_block isinstance in process_stream_line (#542) (#543)

Prevents AttributeError crash when Claude Code stream-json emits a
string element inside a message content array. Guards both
process_stream_line (real-time path) and parse_stream_json_output
(batch path) using the same isinstance(block, dict) pattern already
used by the error_content loop.

Fixes #542

Co-authored-by: Claude <noreply@anthropic.com>

* docs(#411): Phase 1 canary harness design + catalog Phase 1 subset additions (#544)

* docs(#411): Phase 1 canary harness design + catalog Phase 1 subset additions

- New design doc at docs/planning/CANARY_HARNESS_PHASE_1.md scoping the
  AC-required infrastructure (snapshot collector, canary_violations table,
  canary agent template, fleet, alerts) for the three required invariants
  (S-01, E-02, L-03).
- Catalog Phase 1 subset expanded 10 → 12: adds S-03 (slot TTL ≥ exec
  timeout, catches #226) and E-05 (dispatched rows have session, catches
  #106), since both bugs are cited in the catalog motivation but had no
  Phase 1 detector.

* docs(#411): scope fleet to strict minimum for AC's 3 invariants

* docs(#411): expand design doc to cover full Phase 1 (12 invariants, snapshot format)

* feat(settings): add Remove buttons for stored API keys + Slack (#459) (#483)

Settings page lets admins save/test Anthropic API Key, GitHub PAT, and
Slack OAuth credentials, but exposed no UI to clear them once stored.
Only workaround was calling DELETE endpoints directly or editing the DB.

Adds Remove buttons next to Save in each row, conditionally rendered
when the value lives in settings DB (source === 'settings'). Env-var
fallbacks stay uneditable from UI. Confirm dialog before deletion
(reuses ConfirmDialog component + pattern from ApiKeys.vue).

Backend DELETE endpoints already existed — no backend work:
- DELETE /api/settings/api-keys/anthropic
- DELETE /api/settings/api-keys/github
- DELETE /api/settings/slack

Audit of other Settings sections: Trinity Prompt has clearPrompt,
Skills Library blanks via deleteSetting, MCP URL has resetMcpUrl,
GitHub Templates/Email Whitelist have inline remove. Agent Quotas are
config values, not secrets.

Closes #459.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(migrations): swallow duplicate-column race on cold start (#456) (#537)

* fix(migrations): swallow duplicate-column race on cold start (#456)

`_migrate_sync_health` (#389) used a check-then-act PRAGMA → ALTER
pattern that is not atomic across uvicorn workers. On cold start with
`--workers 2`, both workers passed the PRAGMA before either committed
the ALTER, and the loser crashed its child process with
`sqlite3.OperationalError: duplicate column name: auto_sync_enabled`.

Fix:
- Add `_safe_add_column` helper that swallows the duplicate-column
  OperationalError (treats it as success — another worker won the race).
  Future migrations should route ALTER TABLE ADD COLUMN through it.
- Refactor `_migrate_sync_health` to use the helper for both column
  additions and switch the bare `CREATE TABLE` to `CREATE TABLE IF NOT
  EXISTS` (atomic in SQLite).

Tests:
- `test_safe_add_column_swallows_duplicate_column_race` — drives the
  exact production race via a PRAGMA-lying cursor proxy.
- `test_safe_add_column_propagates_other_errors` — non-duplicate errors
  still raise.
- `test_safe_add_column_returns_true_when_added` — happy path.
- `test_migrate_sync_health_idempotent_under_race` — `_migrate_sync_health`
  is now safe to re-run on already-migrated schemas, including under
  the simulated race.

The other ~50 ALTER ADD COLUMN sites are untouched: they're already
applied on production DBs (run_all_migrations short-circuits via the
schema_migrations tracking table). The race only bites new migrations
on first cold-start; the helper is available for them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(migrations): route all ALTER ADD COLUMN through _safe_add_column (#456)

Mechanical sweep of every check-then-act `PRAGMA table_info` →
`ALTER TABLE ADD COLUMN` site through the `_safe_add_column` helper, so
new migrations on a fresh cold-start with `--workers N` are race-safe by
default — not just `_migrate_sync_health` (the originally reported case).

22 migrations refactored. Bare `try/except Exception` swallows in
`_migrate_chat_messages_source_column` and
`_migrate_agent_ownership_voice_prompt` are also replaced with the
helper, which catches only the duplicate-column error instead of every
exception class.

Verification:
- Schema dump (init_schema + run_all_migrations on fresh DB) is
  byte-identical before and after the sweep — every column type,
  default, FK, and index preserved.
- run_all_migrations is idempotent across runs and across fresh
  connections (2nd/3rd runs print no add/create lines).
- 6-worker concurrent stress test (`threading.Barrier`-coordinated)
  completes without any worker crashing; final schema is intact.
- tests/unit/test_migrations_concurrent.py +
  tests/unit/test_migrations.py + tests/unit/test_guardrails.py:
  72 pass, 0 fail.

`tests/unit/test_guardrails.py::test_migration_is_idempotent` updated
to also exec the `_safe_add_column` helper into its isolated
namespace, since the migration now delegates to it.

The two remaining bare `CREATE TABLE` calls in the file
(`_migrate_agent_sharing_table`, `_migrate_agent_skills_table`) are
one-time DROP+CREATE data-recreation migrations that already shipped
on every existing install; they are out of scope for this sweep.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(git): UI Push no longer commits runtime state (#462)

Expands the platform .gitignore deny-list to cover all runtime files
(.env, .mcp.json, .credentials.enc, instance dirs, content/, Claude
Code state, temp files). Adds idempotent migration that updates
existing agents on next Push and calls `git rm --cached` for files
that are now tracked but newly ignored.

Closes #462

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(frontend): semantic status color tokens (#67) (#553)

Introduce 5 semantic status tokens (`status-success/warning/danger/info/urgent`)
in tailwind.config.js as direct aliases of the green/yellow/red/blue/orange
palettes, then migrate 9 frontend files (4 components, 2 panel-local helpers,
1 composable, 1 utility) from raw color classes to the new tokens. Visual
output is byte-equivalent — tokens compile to identical RGB values.

Add CI safety net: `npm run check:tokens` script verifies token-palette
equivalence and catches typo'd token references in source. Wired into a new
frontend-build.yml workflow that runs `npm ci → check:tokens → build` on PRs
touching `src/frontend/**`.

Drive-by fix: rename postcss.config.js → postcss.config.mjs to fix Node
ESM/CJS interop for local `npm run build` (production Docker build was
masking the issue).

70+ raw-color files remain for follow-up sweep (per autoplan phasing).

Fixes #67

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(files): FILES-001 outbound file sharing — MVP + Phase 1 hardening (#491)

* feat(files): FILES-001 outbound file sharing MVP (Steps 1-6)

Implements outbound file sharing per docs/drafts/amazing-file-outbound.md:
- Schema + migration (agent_shared_files table with FK cascade)
- Per-agent opt-in toggle + Docker publish volume (agent-{name}-public)
- Internal share endpoint with path/MIME/size/quota validation
- Public download endpoint (/api/files/{id}?sig=...) with token auth
- share_file MCP tool (agent-scoped)
- SharingPanel UI: toggle, list, revoke, copy URL

Live-verified on Slack: agent→share_file→URL→download end-to-end.
Unit tests: 33 passed (migration, mixin, mount-match).
Known limitations + production readiness plan:
  docs/drafts/amazing-file-outbound-production-readiness.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(files): FILES-001 — requirements, architecture, feature-flow doc

- requirements.md §13.10 new entry marking FILES-001 Implemented (2026-04-24)
- architecture.md: add files.ts MCP module, agent_shared_files_service,
  routers/files.py, the 5 new API endpoints + dedicated section, and the
  agent_shared_files table schema + operational notes
- feature-flows.md: Recent Updates entry + Documented Flows index
- feature-flows/file-sharing-outbound.md: new full vertical-slice doc
  (UI → store → router → service → DB → download) matching the template

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* security(files): cap filename length at 255 chars (C2)

ShareFileRequest.filename and ShareFileMcpRequest.filename get
Field(max_length=255, min_length=1). display_name same cap.
Prevents 10KB+ filename edge cases from agent or attacker.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* security(files): disk-space pre-check before write (C3)

New check_disk_space() helper using shutil.disk_usage('/data').
Refuses writes when /data has less than size_bytes + 500MB free
(HTTP 507 Insufficient Storage). Called before persisting.

Protects shared /data mount — SQLite DB, Vector logs, and log
archives live there too; letting an agent fill the disk causes
platform-wide outage, not just a failed share.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(cleanup): purge expired and old-revoked shared files (C4 / Step 7)

Adds delete_expired_and_revoked(revoke_grace_hours=24) to the DB ops
class (returns stored_filename list for disk unlink) + facade forward
+ wired into cleanup_service.py's 5-min tick.

Per cycle:
- SELECT rows where expires_at < now OR revoked_at < now - 24h
- DELETE them from DB
- unlink each /data/agent-files/{stored_filename}
- bumps CleanupReport.shared_files_purged

The 24h grace on revoked rows keeps them queryable for incident
diagnosis right after revocation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* security(files): dedicated rate-limit bucket for downloads (C5)

/api/files/{id} now uses _check_file_download_rate_limit which keys
redis by file_downloads:{ip} instead of sharing the public_link_lookups
bucket used by /api/public/chat and friends. Limits unchanged (60/min
per IP).

Prevents heavy download traffic from starving the rate-limit quota for
public chat or other /api/public/* endpoints on the same IP.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(files): HEAD handler mirroring GET validation (C6)

Link previewers (Slackbot, Twitterbot, Discordbot, facebookexternalhit)
HEAD-probe URLs before GET. Our endpoint was 405-ing those.

Extracted _validate_download_request() helper from GET; new HEAD
handler reuses it and returns Response(200) with the same headers
(Content-Disposition, nosniff, no-store, Content-Length) but no body,
no download counter bump, no audit row. Follows RFC 7231 §4.3.2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* security(files): tighten list endpoint to owner+admin only (C7)

GET /api/agents/{name}/shared-files previously used can_user_access_agent
(owner/admin/shared). But the list response includes full download URLs
with signed tokens — so anyone able to see the list can reuse every
share. That's the same capability as share_file + revoke, both of which
already require can_user_share_agent.

Change to can_user_share_agent (owner + admin), 403 otherwise.
DELETE was already owner-only; no change needed there.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(prompt): agent nudge for share_file MCP tool (C8)

Add a new 'Sharing Files with Users' section to the system-wide
PLATFORM_INSTRUCTIONS between Collaboration and Operator Communication.
Tells every agent:
  - write files to /home/developer/public/
  - call share_file MCP tool with the relative filename
  - return the URL as-is

This means new agents discover the capability without the user
needing to name the tool explicitly. Applies immediately to every
agent via compose_system_prompt() — no image rebuild needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(pr-491): address validation findings — PII redaction + scope drift

PR #491 /validate-pr flagged two issues:

1. CRITICAL: pavshulin@gmail.com in two draft docs' Owner fields
   (amazing-file-outbound.md, amazing-file-outbound-production-readiness.md).
   Public repo + CLAUDE.md forbids real user emails.
   → Replaced with @pavshulin (GitHub handle).

2. WARNING: .claude/settings.json committed as new file — personal
   Claude Code permission allowlist unrelated to FILES-001 scope.
   → Merged 8 allowlist entries into .claude/settings.local.json
     (gitignored per .gitignore:67). Removed .claude/settings.json.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: fix test-ordering contamination from FILES-001 mixin fixture

Two adjustments surfaced by running the full unit suite:

1. test_file_sharing_mixin.py registered `sys.modules['db']` as a plain
   module (no `__path__`), which poisoned `from db.X import Y` lookups in
   sibling tests (e.g. test_fleet_sync_audit did `from db.schedules ...`
   and hit `'db' is not a package`). Now we give our stub a `__path__`
   pointing at the real db directory, and restore `sys.modules['db']` on
   fixture teardown so no leakage remains.

2. test_start_agent_skip_inject.py didn't mock the new
   `check_public_folder_mount_matches` import added by FILES-001 in
   services/agent_service/lifecycle.py. The Mock container lacked
   iterable `attrs["Mounts"]`, blowing up with TypeError. Stubbed the
   whole `file_sharing` submodule and bound the check on `_mod`
   per-test to return True by default.

Full unit suite now matches dev baseline: 17 pre-existing failures,
701 passing (+33 over dev, all from FILES-001).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by:  Pavlo <pash@pashs-MacBook-Pro.local>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(channels): deliver images as vision content blocks via stream-json (#562) (#566)

Replaces the broken base64 data-URI-in-text approach (where Claude Code
received images as opaque markdown strings) with proper vision content
blocks fed via --input-format stream-json stdin. Images sent through
Telegram (and other channel adapters) are now visible to the agent.

- message_router: _handle_file_uploads returns 4-tuple (added image_data);
  image MIME files collected as {media_type, data} dicts instead of embedded
- task_execution_service: execute_task() accepts images param, forwards in payload
- agent_server models: ParallelTaskRequest.images field added
- agent_server chat router: passes images to runtime.execute_headless()
- claude_code: adds --input-format stream-json and builds JSON content-block
  stdin payload when images present; stdout/stderr threads start before stdin
  write to prevent pipe deadlock; write moved into executor (not event loop)
- runtime_adapter ABC + GeminiRuntime: images param added to prevent TypeError

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(frontend): add state, brand, accent token families (#555) (#561)

Extends the design-system token system from #67 with three additional families
for colors that don't fit the status taxonomy:

  state-*   agent operating modes (autonomous, locked)
  brand-*   third-party product identity (claude, gemini)
  accent-*  decorative highlights named after the literal color so future
            accents (accent-green, etc.) join cleanly

New tokens (all alias full Tailwind palettes, identical visual output):
  state-autonomous → amber   (AutonomyToggle AUTO mode)
  state-locked     → rose    (ReadOnlyToggle ON mode)
  brand-claude     → orange  (RuntimeBadge for Claude Code)
  brand-gemini     → blue    (RuntimeBadge for Gemini CLI)
  accent-purple    → purple  (DashboardPanel widget badges)

Also extends scripts/check-design-tokens.mjs to validate the new families
via a KNOWN_FAMILIES map; the reference scanner now flags typos within any
of the four families (status/state/brand/accent), not just status-*.

Migrates the 4 components blocked by #67's status-only scope:
  - RuntimeBadge.vue       → brand-claude, brand-gemini
  - AutonomyToggle.vue     → state-autonomous
  - ReadOnlyToggle.vue     → state-locked
  - DashboardPanel.vue     → accent-purple slot in getStatusColors

Fixes #555

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(scheduler): agent-owned pre-check hook (#454) (#455)

* feat(scheduler): agent-owned pre-check hook (#454)

New optional contract: agents implement POST /api/pre-check in their
container; scheduler calls it before firing a cron-triggered chat.
Endpoint absent or any error → fire as usual (fail-open). fire=false
records a skipped execution. fire=true with a message overrides the
schedule.message for that invocation.

- docker/base-image/agent_server/routers/pre_check.py: new router that
  dynamically loads /home/developer/.trinity/pre-check.py (template-
  supplied) and calls its check() function
- agent-server main.py: mount pre_check_router
- scheduler/agent_client.py: pre_check() method with fail-open semantics
  on 404/5xx/timeout/malformed-response
- scheduler/service.py: _run_pre_check + pre-check branch in
  _execute_schedule_with_lock (cron only; manual triggers bypass)
- tests/scheduler_tests/test_pre_check.py: 12 tests covering client-
  and service-level behavior; 161/161 scheduler suite passes

Zero schema change — reuses existing ExecutionStatus.SKIPPED and
create_skipped_execution. Closes the "wake agent on every cron tick"
cost gap noted in docs/planning/PR_REVIEWER_AGENT.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(#454): scheduler pre-check feature flow + arch + requirements

- feature-flows/scheduler-pre-check.md: new flow doc with contract,
  fail-open semantics, error table, testing summary
- architecture.md: add /api/pre-check to agent-server endpoint list
  and pre-check note to Scheduler Service row
- requirements.md: SCHED-COND-001 entry under §10 (Scheduling & Execution)
- feature-flows.md: index row
- docs/planning/PR_REVIEWER_AGENT.md: design doc from which this
  feature was extracted — committed for traceability

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* review: address PR #455 review feedback

- pre_check.py: asyncio.get_event_loop() → get_running_loop() (deprecated in 3.10+)
- pre_check.py: oversized message override no longer dropped silently —
  response now carries message_truncated="override dropped: N bytes exceeds
  32000 cap" so scheduler/operator can see what happened; log escalated to
  ERROR with size+limit details
- pre_check.py: module-level docstring expanded to note the security scope
  of check() (full Python interpreter access, same sandbox as chat tools —
  operators should review .trinity/pre-check.py like any executable template
  file) and the intentional no-cache behavior
- tests/unit/test_pre_check_router.py: 15 new router/unit tests covering
  oversized-message drop path and non-dict return → 500 (both previously
  only exercised by inspection). Uses importlib to load pre_check.py
  directly, avoiding python-multipart requirement from sibling routers
- feature-flows/scheduler-pre-check.md: document truncation behavior,
  security scope expectation, and updated test summary (12 scheduler +
  15 router = 176 total passing)

Lock-scope concern noted in review is not an issue: the skip path returns
from _execute_schedule_with_lock, and the outer _execute_schedule holds
the lock in a try/finally that covers the return. No leak.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(#454): docker exec instead of agent-server HTTP endpoint

Review feedback on #455 flagged that the HTTP-endpoint design introduced
a new system edge (scheduler → agent-server direct) and a novel code-
loading pattern (importlib in a router). Both broke with Trinity's
established convention that all "run something in an agent container"
flows go through `services/docker_service.execute_command_in_container`
— the same primitive used by:

- services/git_service.py (persistent-state allowlist, #384 S3)
- services/ssh_service.py (key provisioning)
- services/agent_service/terminal.py (web SSH)
- routers/system_agent.py (admin exec)
- adapters/message_router.py (Slack file ingest)
- routers/voice.py, monitoring_service.py

This commit swaps the design accordingly.

Changes:
- Delete docker/base-image/agent_server/routers/pre_check.py and its
  router registration. No new HTTP surface on agent-server.
- Delete tests/unit/test_pre_check_router.py (router is gone).
- Add src/backend/routers/internal.py →
  POST /api/internal/agents/{name}/pre-check. Runs the template-shipped
  `.trinity/pre-check.py` via execute_command_in_container. Two-step:
  `test -f` for existence, then `python3 .../pre-check.py`. Returns
  {hook_present, exit_code, stdout, stderr}. Gated by existing
  X-Internal-Secret header (C-003).
- Rewrite src/scheduler/service.py::_run_pre_check to call the backend
  endpoint (scheduler no longer opens a direct edge to agent-server).
  Translates …
vybe pushed a commit that referenced this pull request May 4, 2026
* fix(security): split Docker compose into platform and agent networks (#589)

Redis at 172.28.0.0/16 was reachable from any agent container. AISEC scan
3aad5469 demonstrated end-to-end exfiltration / cross-user task injection
from a legitimately deployed agent. Network segmentation is the strongest
control — agents now physically cannot route to Redis.

Topology:
- trinity-platform (172.29.0.0/16, NEW) — Redis, scheduler, vector
- trinity-agent (172.28.0.0/16, name preserved) — frontend, agents
- Backend / mcp-server / otel-collector / cloudflared straddle both

Agent-creation sites in services/agent_service/* and system_agent_service.py
need zero changes because the agent-network external name is preserved.

Dev: bind Redis host port to 127.0.0.1:6379 (was 0.0.0.0). Tests connect
from the dev machine; LAN cannot. Auth lands in the next commit.

Refs #589 — acceptance criterion #3 (network segment separation).

* fix(security): mandatory Redis auth, ACL users, auth-aware healthcheck (#589)

Both compose files now enforce two passwords (REDIS_PASSWORD admin /
REDIS_BACKEND_PASSWORD runtime) with the fail-on-missing :? form.
docker compose refuses to render without them.

Per-user ACL via inline --user flags. Additive (start from zero, allow
only what the runtime needs) — never +@ALL -X, which lets newly added
dangerous commands through. backend + scheduler get standard data
families plus scripting/transactions/pubsub minus -@dangerous, which
covers FLUSHALL, CONFIG, SHUTDOWN, MIGRATE, REPLICAOF, MONITOR.

Verified at runtime against redis:7-alpine: PING/SET/GET work for the
backend user, FLUSHALL and CONFIG GET return NOPERM, unauth requests
return NOAUTH.

REDIS_URL on backend + scheduler now embeds the backend ACL user.
mcp-server: REDIS_URL and depends_on:redis dropped in prod compose
(zero Redis imports in src/mcp-server/).

Healthcheck pings as the backend ACL user so a typo'd ACL keeps redis
unhealthy and gates dependent services. depends_on:redis switches to
service_healthy so backend/scheduler don't race the ACL load.

Refs #589 — acceptance criteria #1, #2, #5.

* fix(scheduler-test-rig): mirror Redis auth posture (#589)

Without this, scheduler container fails fast on startup against the rig
because src/scheduler/config.py requires creds in REDIS_URL after #589.
No ACL or network split here — this is a 2-service standalone debugging
rig, not the production posture.

* fix(security): fail-fast on REDIS_URL missing credentials (#589)

Backend (src/backend/config.py) and scheduler (src/scheduler/config.py)
now raise RuntimeError at import time if REDIS_URL is unset or lacks
credentials.

Removed the splicing fallback in backend config that papered over an
unauth REDIS_URL by joining REDIS_PASSWORD into the URL — single source
of truth (compose) eliminates silent drift.

Tests that import backend modules need a creds-bearing REDIS_URL in their
environment; tests/conftest.py will set a dummy one in the test commit.

Refs #589 — acceptance criterion #5.

* fix(webhooks): use REDIS_URL for rate-limit client (#589)

Webhooks rate-limit was the one Redis client that bypassed REDIS_URL —
it used redis.Redis(host="redis", port=6379) and would silently
fail-open under requirepass. Switching to redis.from_url(REDIS_URL)
picks up the credentialed URL like every other client.

Also: distinguish auth/ACL errors (logged at ERROR with exception class)
from transient errors (WARN). Fail-open behavior preserved so a Redis
blip doesn't 500 legitimate webhooks, but a misconfigured deploy now
surfaces in alerts instead of via a webhook abuse incident.

Drops the now-unused REDIS_HOST/REDIS_PORT env reads.

* feat(deploy): auto-generate Redis passwords on fresh installs (#589)

start.sh ensure_redis_passwords matches the existing
CREDENTIAL_ENCRYPTION_KEY pattern, with one safety guard:

- Fresh install (no redis-data volume) → generate both passwords with
  openssl rand -hex 24 and append to .env. One-command boot keeps
  working.
- Existing volume + missing password → refuse with a loud error pointing
  at docs/migrations/REDIS_AUTH.md. Re-keying a populated Redis would
  lock the backend out of its own data; ops needs to follow the explicit
  upgrade path.

Idempotent — second run is a no-op when both passwords are already set.

* docs(security): add Redis auth migration guide + architecture notes (#589)

- docs/migrations/REDIS_AUTH.md: operator upgrade guide. Covers fresh
  installs (auto-generated by start.sh), live upgrades (down
  --remove-orphans + docker network rm + add passwords), production,
  and verification commands.
- docs/memory/architecture.md: new "Network Topology (Issue #589)"
  section above Container Security. Documents the two-network split,
  service membership table, the "agents NEVER on platform network"
  rule, and the three Redis ACL users + their access patterns.

* test(security): network isolation, ACL, fail-fast, webhook rate-limit (#589)

tests/conftest.py: top-level autouse env stub for backend imports.
Backend config now raises at import-time if REDIS_URL lacks credentials;
without this, every test that transitively imports backend modules
breaks. Real Redis tests under tests/security/ override via their own
conftest from .env. Adds the `integration` marker.

tests/unit/test_config_fail_fast.py (new): backend refuses to import
without creds-bearing REDIS_URL. 3 cases — missing env, unauth URL,
URL with creds.

tests/security/test_redis_network_isolation.py (new): 5 integration
tests covering acceptance criteria #1-#3:
  - agent-network container has no route to redis (BLOCKED)
  - unauth client gets NOAUTH on platform network
  - backend ACL user can PING with creds
  - backend ACL user FLUSHALL → NOPERM (no admin)
  - backend ACL user CONFIG GET → NOPERM (no requirepass leak)

tests/security/conftest.py (new): session-scoped fixture loads real
.env values for the integration tests; skips the suite if missing.

tests/integration/test_webhook_rate_limit.py (new): regression for the
from_url switch in webhooks.py. Self-contained — creates agent +
schedule + webhook token inline, hits 11×, expects 429 on the 11th.
Catches the silent fail-open if Redis auth ever regresses.

tests/run-integration.sh (new): pytest -m integration runner. Excluded
from run-smoke.sh per the smoke runner's ~30s no-Docker contract.

* docs(security): detach agents before network rm (#589)

Trinity-managed agent containers are created via the Docker SDK
outside compose, so they store the agent network's UUID, not its
name. After `docker network rm trinity-agent-network` (step 3 of
the upgrade procedure), any later `docker start <agent>` fails:

    Error response from daemon: failed to set up container
    networking: network <old-uuid> not found

Compose-managed services don't hit this — they're recreated with
fresh network refs on `up`. Agent containers aren't, so they keep
the stale UUID until disconnected.

Add an explicit detach loop as step 2, before the network removal.
Verified against a populated install with one running and four
stopped agents: all five reattach cleanly to the new network on
next start.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(security): CSO OBS-1/2/3 follow-ups — webhook rate-limit + healthcheck hardening (#589)

Resolves three observations from the CSO audit
(docs/security-reports/cso-2026-05-04-589-diff.md):

OBS-1 — webhook rate-limit fail-open + connection-per-request DoS amplifier:
* Added in-process secondary rate limiter (3x primary, per-worker) in
  src/backend/routers/webhooks.py. Bounds blast radius during a Redis
  outage without breaking the documented fail-open philosophy.
* Cached the Redis client at module level under threading.Lock with
  double-checked init. _check_webhook_rate_limit resets the cache on
  inner exceptions so stale connections rebuild cleanly. Without
  caching, a flood would open a fresh TCP per request and exhaust
  Redis maxclients — turning the rate limiter into the DoS amplifier.

OBS-2 — tightened _TOKEN_RE from {20,60} to {43} matching
secrets.token_urlsafe(32) (verified against db/schedules.py:524).

OBS-3 — switched all three compose healthchecks from
`redis-cli -a $$PASS` to `REDISCLI_AUTH="$$PASS" redis-cli` so the
password no longer appears in /proc/<pid>/cmdline.

Additional #589 hardening (caught while resolving OBS-1):
* src/backend/config.py + src/scheduler/config.py: tightened the
  REDIS_URL credential check from `"@" in url` substring to urlparse
  validation. Catches redis://@redis:6379, redis://user@redis:6379, etc.
* src/scheduler/main.py: redact password from REDIS_URL before logging
  (was leaking via Vector log aggregator).

Tests:
* tests/unit/test_webhook_rate_limit_inprocess.py — 7 new tests covering
  cap, window expiry, token isolation, runtime-error fallback, regex
  shape, cache hit, cache reset.
* tests/unit/test_config_fail_fast.py — 4 new parametrized cases for
  malformed-credential URL rejection.
* 15/15 unit tests pass.
* Live Redis healthcheck verified — trinity-redis reports healthy with
  the new REDISCLI_AUTH form; `redis-cli ping` returns PONG.

Also adds .gstack/ to .gitignore so future skill artifacts stay local.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
vybe pushed a commit that referenced this pull request Jul 29, 2026
…cold boundary (#1816) (#1867)

* docs(system-agent): base-image adoption semantics for trinity-system (#1816)

Rule #1 — documentation before implementation.

- architecture.md: #1560 lifecycle-clearing wording ("never evaluated" →
  "never acted on"), the 3-state split, and the structural AC2 gate; the
  system_agent_service catalog entry.
- feature-flows/internal-system-agent.md: startup diagram gains the drift
  branch, plus a Base-image adoption section carrying the convergence
  invariant, the three boundaries, the AC2 gate and the consequences
  (writable-layer loss, no TRINITY_BACKEND_URL, operator-triggered on the
  canonical upgrade path).
- feature-flows/agent-lifecycle.md: 3-state core + boolean wrapper,
  system-aware capabilities predicate + recreate override, restart-policy
  carry-forward.
- requirements/infrastructure.md: new 8.5b ADOPT-001..005.
- feature-flows.md: Recent Updates row.

Refs #1816

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(agents): 3-state base-image check + system-aware capabilities predicate (#1816)

T1 — `check_base_image_matches` split into a 3-state `check_base_image_state`
core (`match` | `drift` | `unknown`) plus an unchanged boolean wrapper
(`state != "drift"`). Every WARNING is kept verbatim and the recreate path
still consumes only the boolean, so #1809's behaviour is byte-identical:
`unknown` and `match` both fail open to True. The 3-state exists because the
staleness alarm this issue adds cannot be built on a boolean whose True means
both "the image is current" and "the check could not run" — alarming on that
would recreate the #1809 symptom one layer up.

T2 — `check_full_capabilities_match` is system-aware. `trinity-system` runs
FULL_CAPABILITIES by contract (package installation), not by the fleet
default this predicate compares against, so pinning its
`trinity.full-capabilities` label alone would, on any install with
`agent_full_capabilities=false`, produce a mismatch that can never
converge — a recreate on every start, forever.

Both route through one shared `is_system_agent_name()` so the checker, the
recreate override and the AC2 gate can never disagree. Deliberately a NAME
test rather than `db.is_system_agent`: it must be unfailable (a DB error that
flips this answer would either recreate the orchestrator or leak full
capabilities) and it must not widen the exemption to any `is_system`-flagged
row.

Verified: tests/unit/test_1809_image_drift_recreate.py (19) plus
test_start_agent_skip_inject, test_subscription_auto_switch_no_cred_import,
test_inject_assigned_credentials, test_agent_readiness_probe,
test_1560_breaker_cleared_on_lifecycle, test_1811_recovery_container_parity,
test_base_image_allowlist, test_1484_create_agent_characterization — 102 passed.

Refs #1816

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(system-agent): converge creation on the recreate path's contract (#1816)

T3 + T-A. `_create_system_agent` left two of the eight config predicates
PERMANENTLY false:

  * `check_agent_auth_token_env_matches` (#1159) — the env dict never wrote
    TRINITY_AGENT_AUTH_TOKEN; its only writers were crud.py and the two
    lifecycle recreates.
  * `check_full_capabilities_match` — the container runs with
    cap_add=FULL_CAPABILITIES but carried no `trinity.full-capabilities`
    label, and a missing label reads as 'false' against a fleet default of
    true.

That is not cosmetic. `recreate_container_with_updated_config` resolves the
image from the container's own Config.Image *tag*, so every config recreate
is also an image adoption — a permanently-false predicate means the first
`POST /api/agents/trinity-system/start` after any fresh provision replaces a
RUNNING orchestrator and swaps its image mid-operation, which is precisely
what AC2 forbids. (Convergent: a recreate writes both values, so only the
first start was affected — which is why this survived so long.)

The token derive is fail-closed on an unset AGENT_AUTH_SECRET, accepted
deliberately: the install now fails to CREATE the system agent rather than
creating one the backend can never talk to. ensure_deployed catches →
`create_failed`, and main.py's lifespan catch keeps boot alive.

Deliberately NOT added: TRINITY_BACKEND_URL. It gates the agent-side
heartbeat loop, and authorize_heartbeat accepts only scope='agent' keys —
the system agent's is scope='system', so arming it is a permanent 5s 403
loop. Pinned by an AST guard (key-level, not substring — the code documents
the omission and a text search would fire on its own rationale).

tests/unit/test_1816_system_agent_convergence.py drives `_create_system_agent`
for real and builds the fixture from the `environment`/`labels` kwargs it
actually passes to containers_run — a hand-built fake carrying both values
would assert only that a correct container is correct. Red before this commit
on ['agent_auth_token'] + both pins; 11 passed after.

Refs #1816

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(agents): restart-policy carry-forward, capability override, AC2 gate (#1816)

T4 — three lifecycle fixes the adoption path depends on:

* **Restart policy is carried onto the replacement.** `old_host_config` was
  extracted at the top of `recreate_container_with_updated_config` and then
  never read, so `unless-stopped` silently vanished from EVERY recreated
  agent. `trinity-system` is created with it, so one recreate downgraded the
  platform orchestrator to "stays down after a crash or host reboot".
  `_provision_folders_and_run_agent_container` takes a keyword-only
  `restart_policy` and forwards it only when it names a policy, so every
  pre-#1816 caller is byte-identical. Read null-safely — the key can exist
  with a null value and `.get` on None would abort the recreate after the old
  container is already gone.
* **`full_capabilities` override.** `None` (every existing caller) resolves to
  the fleet default for a regular agent and unconditionally True for
  trinity-system, via the same `is_system_agent_name` the predicate exempts
  on — writer and checker cannot disagree.
* **No `TRINITY_BACKEND_URL` for the system agent.** It gates the agent-side
  heartbeat loop and `authorize_heartbeat` accepts only scope='agent' keys;
  the system agent's is scope='system'. #1816 makes this recreate a routine
  path for it, so arming it would newly create a permanent 5s 403 loop.

T5 (AC2) — `start_agent_internal` gains the structural gate: a RUNNING
trinity-system is never recreated, and the caller is told
`recreate_deferred="system_agent_running"` rather than left to infer it. The
gate covers the WHOLE `needs_recreation` block, not just the image predicate:
the recreate resolves the image from a tag, so any predicate that fires is
also an image adoption — gating one would leave AC2 open through the other
eight. Surfaced through `routers/agents.py`'s whitelisted response dict and
its audit details (a field added to the internal dict alone dies at the
router — #1809's own learning).

Two harness stubs updated: a bare `Mock()` auto-creates `is_system_agent_name`
returning a truthy Mock, which reads as "every agent is the system agent" and
silently suppressed every recreate in test_start_agent_skip_inject.

Verified per-file (all green): test_start_agent_skip_inject 9,
test_1809_image_drift_recreate 19, test_1816_system_agent_convergence 11,
test_agent_readiness_probe 5, test_inject_assigned_credentials 8,
test_subscription_auto_switch_no_cred_import 1, plus the 12 other
helpers-stubbing files (91 tests). A cross-file sys.modules ordering flake in
TestCheckBaseImageMatches (9 tests) reproduces IDENTICALLY on pristine
origin/dev @64845458 — pre-existing, not a regression.

Refs #1816

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(system-agent): adopt a rebuilt base image at the cold boundary (#1816)

The core of the issue. `ensure_deployed` returned `action: none` the instant
the container reported `running`, without evaluating a single drift predicate.
Combined with `restart_policy: unless-stopped` and a canonical upgrade path
(build-base-image.sh → start.sh) that never touches agent containers, that
made the platform orchestrator the most-stale agent in every fleet —
indefinitely, and silently.

T6 — three boundaries, honestly separated:

  * RUNNING → READ-ONLY. Reports `base_image_state`
    (`current`|`stale`|`unknown` — an enum only, never image ids, mirroring
    the /health clone_status contract #1439), WARNs naming the remedy, and
    never recreates. Source-pinned by slicing the branch between two named
    anchors, so a refactor that merely relocates a recreate call cannot pass.
  * STOPPED → delegates to `start_agent_internal` instead of a bare
    `container_start`. This is the cold boundary where the #1809 image gate
    fires, and it inherits #1560 clear-before-recreate ordering, the
    409/NotFound race hardening, the post-recreate handle re-lookup and every
    future predicate — rather than forking a second lifecycle for one agent,
    which is the bug class that produced this issue.
  * no container → unchanged.

Every early return sets both `action` and `message`: main.py's lifespan
indexes them directly, so an omission would raise KeyError inside the boot
log line.

T7 — AC1 on the canonical upgrade path. Since the system agent is RUNNING
after every canonical upgrade, the read-only branch is the one that fires,
so detection without notification would tell no one. An edge-triggered
operator-queue alarm follows the sync_failing/git_bloat idiom (reserved id
prefix registered in #1632's anti-spoof guard, priority high, 6h cooldown,
emit-failure-safe) and is raised on `stale` ONLY — a fail-open probe must
never manufacture an alert, which is exactly why the 3-state split exists.

R1 pre-flight: a recreate REMOVES the old container before running the
replacement, so a run failure leaves the platform with no orchestrator. When
the agent network is missing or the ssh port is bound, the adoption is
declined and a plain start runs instead (costing one stale boot — the
pre-#1816 status quo — rather than the orchestrator). Fail-open on an
unreadable probe; a start failure raises a critical alarm.

T8 — /restart delegates (an explicit stop makes it a cold start, so it is the
operator's remedy for the alarm) and re-fetches the container for its
response, because a recreate replaces the handle. /status gains the 3-state
`base_image_state`, reported only while running. /reinitialize deliberately
unchanged, pinned by a test.

T10 — 49 behavioural cases + the source pins, and the test_1560 create-path
pin repaired: `str.index` is first-occurrence, so it stayed green while
silently ceasing to pin `_create_system_agent` the moment anything above it
grew a `clear_agent_breakers(SYSTEM_AGENT_NAME)` call. Proven by simulation —
the old assertion PASSES with the create-path clear removed, the repaired one
fails with "the clear must live INSIDE _create_system_agent".

Verified: test_1816_system_agent_adoption 49, test_1816_system_agent_convergence
11, test_1560_breaker_cleared_on_lifecycle 16, test_1632_operator_queue_caps 49
— 118 passed. tests/lint_sys_modules.py: no new violations.

Refs #1816

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(agents): name the shared helper correctly (is_system_agent_name) (#1816)

The docs written ahead of implementation used `is_system_agent()`, which
collides conceptually with the existing DB-backed `db.is_system_agent`. Names
the real helper and states why it is a name test rather than the DB one.

Refs #1816

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(tests): sync the test catalog and the two remaining feature flows (#1816)

/update-tests + /sync-feature-flows tail steps.

- .claude/agents/test-runner.md: both new unit files catalogued under
  Operations & Observability, a dated Recent Test Additions block (including
  the test_1560 pin repair and the two harness-stub fixes), and the totals.
- feature-flows/async-docker-operations.md: the new `network_get` wrapper.
- feature-flows/operating-room.md: `base-image-stale-` registered in the
  reserved-prefix enumeration (all 3 sites) and named in the platform-create
  exemption list.

agent-lifecycle.md and internal-system-agent.md were already synced in the
docs-first commit; feature-flows.md index is 430 lines.

Refs #1816

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(system-agent): make the state-label map public, merge the duplicate gate (#1816)

Self-review polish, no behaviour change:

- `_BASE_IMAGE_STATE_LABELS` is consumed by `routers/system_agent.py`, so a
  leading underscore was wrong — renamed `BASE_IMAGE_STATE_LABELS`.
- `get_system_agent_status` had two consecutive `if status == "running":`
  blocks; folded the health fetch into the first. It keeps its own
  try/except, so `base_image_state` is still set when the agent is
  unreachable.

Verified: test_1816_system_agent_adoption + test_1816_system_agent_convergence
— 60 passed.

Refs #1816

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(system-agent): pass the container into the adoption pre-flight (#1816)

The stopped branch already holds the handle; re-fetching it inside
`_preflight_ok_for_delegated_start` was a wasted Docker round-trip on the boot
path and a needless TOCTOU window. Still null-safe throughout, so a partially
populated handle degrades to "no port to check" rather than raising.

Verified: 60 passed (adoption + convergence).

Refs #1816

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(system-agent): make the #1816 suites CWD-independent (#1816)

Caught by verify-local, not by any local run: `_create_system_agent` resolves
its template through a CWD-RELATIVE fallback (`./config/agent-templates`, used
whenever `/agent-configs/templates` is absent, i.e. off-container), and
verify-local runs pytest from `tests/`. From there creation died on a missing
template BEFORE reaching the token derive, so
`test_creation_without_agent_auth_secret_fails_closed_without_blocking_boot`
was asserting the wrong failure — green from the repo root, red from `tests/`.

Both suites now pin the CWD to the repo root with the reason stated, so they
hold wherever pytest is invoked from. Verified from BOTH cwds: 60 passed each.

A test bug, not a code bug — the behaviour under test is unchanged.

Refs #1816

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(system-agent): state the staleness alarm's per-worker reach (#1816)

`ensure_deployed` runs once per worker lifespan and the cooldown cursor is
per-process, so a stale boot files one operator-queue item per worker
(`--workers 2` ⇒ 2). Deliberate rather than overlooked — a cross-worker cursor
puts Redis or a DB read on the boot path for an advisory alarm, and the
un-guessable timestamped id (what stops an agent pre-creating and silencing it,
per #1632) is inherently undedupable by `on_conflict_do_nothing`. Said plainly
in both the code and the flow doc rather than left for a reviewer to discover.

Refs #1816

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(system-agent): fence the delegated start, gate the operator remedy (#1816)

Review + CSO follow-ups on the base-image adoption work.

- recreate_missing_container refuses trinity-system (409). start_agent_internal
  falls through to it when the container lookup returns None, and #1816 newly
  reaches that from the boot path and /restart — ensure_deployed runs in every
  uvicorn worker with no leader lock, so a concurrent recreate can null the
  lookup mid-flight. That path reconstructs a REGULAR agent: it deactivates the
  system-scoped MCP key and mints an agent-scoped one (plaintext unrecoverable,
  so the orchestrator irreversibly loses its permission bypass), drops
  trinity.is-system, the /template bind and unless-stopped, and arms the
  scope-403 TRINITY_BACKEND_URL. ensure_deployed's create branch rebuilds it
  correctly on the next boot; the race itself is #1817.

- /restart and /reinitialize are human-only. assert_admin rejects connector
  principals but not agent ones, and get_current_user hands an agent-scoped key
  its owner's role — so on a default admin-owned install any non-ephemeral
  agent's TRINITY_MCP_API_KEY passed it. Tolerable while /restart was a
  stop+start; not once it replaces the container. trinity-ops-agent#232
  precedent; no-op for JWT / user-scoped / system-scoped callers.

- Sanitize the start-failure alarm's interpolated exception string. It lands in
  operator_queue.question — durable, operator-visible state — from a path that
  builds env dicts holding OAuth tokens and PATs. The staleness alarm was built
  to carry no identifiers; this one wasn't.

- Bound that alarm across processes via a bucketed id. A per-process cursor
  cannot bound a failure whose symptom is a fresh process, and retention never
  deletes a pending row. A bucket (not a fixed id) so Clear All → cancelled
  can't wedge it shut through on_conflict_do_nothing; guessability is fine
  because the prefix is in _RESERVED_ID_PREFIXES.

- Cooldown cursor uses time.monotonic() — datetime.utcnow() is deprecated and a
  wall-clock step could skip or extend the gate.

- State the real boot cost in the comment (~20-90s of blocked lifespan, not one
  timeout) and name the multi-worker exposure.

9 tests; 5151 unit tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(system-agent): sync architecture.md with the review-pass fences (#1816)

The last commit added two fences but only reached requirements + the feature
flow. architecture.md is the current-design doc and carries the invariant, so
both belong here too.

- system_agent_service entry: recreate_missing_container refuses trinity-system
  (409, ADOPT-006). Worth stating where the service is described, because the
  reader's natural assumption is that the generic recovery rebuild is a valid
  way to bring the orchestrator back — it is not, and the downgrade it causes
  (system-scoped MCP key deactivated for an agent-scoped one) is irreversible.

- Invariant #8 gains "Role ≠ human": assert_admin/require_admin answer what
  role, never is-this-a-human, because get_current_user resolves an
  agent-scoped MCP key to its owner carrying the owner's role. Generalized from
  the two known instances (#1644 retention ack, #1816 /restart + /reinitialize)
  into the rule that produced them — an endpoint whose blast radius is
  operator-scale needs reject_agent_principal in ADDITION to the role gate, and
  the trigger to revisit an existing gate is a change in what the endpoint
  DOES. Escalating a handler's destructiveness silently re-prices every
  principal that could already reach it.

Refs #1816

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(system-agent): pin the real modules against sibling sys.modules leaks (#1816)

The adoption suite passed alone and in the full alphabetical run, but 21 of its
67 tests failed when collected alongside tests/unit/test_start_agent_skip_inject.py
— i.e. it was green by luck of import order, and CI randomizes.

Five sibling unit files replace services.agent_service / .helpers with Mock
objects in sys.modules at COLLECTION time (the grandfathered #762 class). This
file resolves the real modules lazily, inside fixtures — after the
contamination. The docstring already noted the file is not a contaminator; not
being one is not the same as being immune to one.

The failure mode is silent rather than loud, which is the reason to fix it
rather than order the files: a leaked helpers Mock makes is_system_agent_name
return False for everything, so the AC2 gate never fires and the recreate it
exists to suppress happens — while every assertion still looks meaningful.
check_base_image_state degrades the same way (a Mock can never return
"unknown", so the never-alarm-on-unknown property becomes untestable).

Fix: import the real modules at collection time (this file sorts before all
five) and pin them per-test with monkeypatch.setitem, which self-restores — so
the siblings' own harnesses, which hold direct module references rather than
sys.modules lookups, are untouched. No new lint_sys_modules violations
(monkeypatch.setitem is the sanctioned form).

Reproduction now green: adoption + skip_inject 21 failed -> 0; the original
5-file set 21 failed -> 108 passed.

Refs #1816

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>
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>
dolho added a commit that referenced this pull request Jul 30, 2026
…ent#180)

An exposed agent's card advertises every `template.yaml capabilities[]` tag, and
the well-known discovery route is unauthenticated by design — so today that full
capability list is world-readable for any exposed agent. This lets an operator
choose what the outside is told.

**A disclosure control, and the code says so.** The card's `skills[]` is
advertisement: inbound `message/send` dispatches free-form text via
`execute_task(triggered_by="a2a")`, with no per-skill routing. Filtering changes
what an orchestrator SEES, never what it may ASK for. That's stated in the
requirement, the seam docstring and the filter itself, because a filter
operators mistake for an invocation gate is a control that looks like security
and isn't. A real boundary (constraining a2a-triggered runs via
allowed_tools/guardrails) is separate work with its own threat model.

Extends the existing ent#157 seam rather than adding a module — same shape as
the inbound allow-list, second provider:

    provider.exposed_skills(agent_name) -> Optional[List[str]]

- No provider (OSS) → identity function; the card is byte-identical to before,
  by construction. The enterprise module owns the config, storage and UI.
- `None` = no opinion = advertise all: the unconfigured default, so exposure
  (already opt-in, default OFF) keeps every existing card unchanged on upgrade.
- `[]` ≠ `None`: an explicit "advertise nothing".
- Stale ids are inert — the selection only subtracts; `template.yaml` stays the
  source of truth for what exists.
- Fail-open on provider error (advertise all + WARNING): consistent with the
  seam's availability bias and the advertise-all default. Honest only *because*
  this isn't a security boundary — failing closed would silently empty a card
  and break discovery invisibly.

Both card surfaces (public well-known + authenticated per-agent) go through one
router helper, so they can't disagree and a future third surface gets the filter
by default rather than by remembering. `generate_a2a_card` stays pure — the
provider lookup lives in the helper.

Writing the tests found a real gap: a provider returning a str (a defect) would
iterate into single characters, match no id, and silently empty the card —
fail-CLOSED, the opposite of the contract, and invisible. Malformed returns now
take the same fail-open path as a raised error.

Requirements §32.4 written before the code (CLAUDE.md rule #1); public docs
describe the generic seam only, per the standing enterprise-docs rule.

Related to trinity-enterprise#180
vybe pushed a commit that referenced this pull request Jul 31, 2026
….8 amend + new §9.9 (ent#260)

Requirements-first (Rule #1): records the third dashboard mode (Timeline /
Grid / List), the 28-item parity disposition, chassis filter/create migration,
the /agents → /?view=list one-shot non-persisting redirect, NavBar
consolidation, and the zero-backend-change / N+1-deletion performance shape.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
vybe pushed a commit that referenced this pull request Jul 31, 2026
…cator (ent#264)

New §15.1h: reaction ack + elapsed-time placeholder + channel-agnostic
start/progress/resolve seam, group gating (mention/reply OR all-mode;
observe stays silent), degradation ladder, default-ON per-binding toggle,
GET /telegram access hardening. §15.1c/§15.1e touch-ups.

Requirements-first per Rules of Engagement #1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
vybe pushed a commit that referenced this pull request Jul 31, 2026
…ote updated (ent#261)

Trinity Rule #1: requirements before implementation. §9.10 specifies the /
hotkey filter across Timeline/Grid/List — store-seam predicate, pre-query node
invariant, pill honesty, Esc layering, chassis query-empty overlay, kbd hint,
list-mode composition, and the deliberate timeline owner-filter behavior change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
AndriiPasternak31 added a commit that referenced this pull request Jul 31, 2026
…metadata

Closes ent#128 AC #1-2. A template can now describe each credential an operator
must supply — title, description, required, secret, format, setup_url, default —
and `template_service` surfaces the normalized result as `credential_requirements`
on every catalog entry.

**Enrichment lives in a NEW sibling top-level key; `credentials:` is FROZEN as
names-only, forever.** An already-deployed older Trinity reads `env_file` through
`credential_env_file_names` and then does `agent_credentials.get(var_name, "")` —
hand it a list of mappings and that is `TypeError: unhashable type: 'dict'` at the
moment it writes the agent's `.env`. A sibling key is structurally invisible to
that binary, so there is no floor version and enrichment distributes immediately.

**Base-set-plus-overlay, so the two keys cannot drift.** One record per variable
`credentials:` declares, decorated by `credential_setup:` entries joined BY NAME.
An entry naming nothing is a named three-line error (problem, cause, FIX) and is
dropped; valid siblings survive. `credential_setup:` can only ever decorate — the
sibling-key shape's usual failure mode is closed by construction, not by
discipline. Stated honestly: for an EXTERNAL template that error is neither
impossible nor visible in the UI — `credential_errors` has zero frontend and zero
MCP consumers, so the only human channel is the backend log. It is LOGGED.

`required` is a tri-state. Enriched-and-omitted means `True` (an author who
described a variable meant it); a legacy bare `- FOO` is `"unknown"`, never `True`
— it carries no authorial intent, and reading it as required makes a guided
checklist cry wolf. `"unknown"` doubles as the enriched/un-enriched discriminator,
which is why no `enriched: false` flag is needed. `secret` defaults `True`
(fail-safe). Path-free by construction, so trinity#570's `template.yaml` →
`trinity.yaml` rename cannot reach it.

**The normalizer never raises, and that is load-bearing.** `_build_template` runs
in bare list comprehensions in `get_all_templates()`, OUTSIDE PR-A's per-template
fence (which covers `_build_local_template` only) — a raise there is HTTP 500 with
an EMPTY CATALOG, i.e. PR-A's exact bug reopened by the change that surfaces the
new metadata. And no bomb is needed: `title: 123` or a bare `title:` was enough.
So the builders ALSO wrap the call and degrade to `[]` plus a named error, rather
than fencing the comprehension — that keeps the named error the resilience
contract promises. The property does not rest on one function's discipline.
(Which earned its keep immediately: the wrapper caught a real NameError during
development instead of emptying the catalog.)

Trust boundary — `title`/`description`/`setup_url`/`name`/`source` are
author-controlled strings from arbitrary GitHub repos flowing into an
operator-facing "paste your API key" checklist:

  * **Type-guard before touching.** Never `str()` a container from untrusted YAML:
    `str()` EXPANDS a shared alias during the walk (443 B → 52 MB in 1.5 s, x10 per
    level), and both the sanitizer and the record cap act after that cost is paid.
  * **Cap the INPUT**, entries AND errors AND the base set. Capping records while
    leaving `errors` uncapped built a 35 MB response out of the cap meant to
    prevent it; and `default` had no type row, so the 100-record cap acted as a
    x100 multiplier on it.
  * **`source` is sanitized** — it carries the raw MCP server name, the exact
    string `_sanitize_for_warning`'s own docstring names as the threat, and it was
    not on the list.
  * **Per-field length caps.** Reusing the 80-char terminal-warning default
    truncated a realistic 159-char description and made a real 90-char vendor
    console URL unusable.
  * **`setup_url` above scheme-only**: https (case-insensitive — `HTTPS://` is a
    legitimate author), a parseable host, NO userinfo (`https://google.com@evil.tld`
    renders as one host and resolves to another — the display/resolve split IS the
    attack), ≤2048, printable. Validate THEN sanitize, and never through a
    truncator. Residual documented, not claimed closed: `isprintable()` rejects
    RTL/ANSI but an IDN homograph survives, so a consumer must render the parsed
    hostname beside the link.
  * **Never mutates its input** — `_metadata_cache` holds the parsed dict for 600 s
    and YAML aliases genuinely share nodes, so one in-place normalize would rewrite
    both aliased fields and persist for ten minutes. Asserted against a deep-copy
    snapshot, including a real `&anchor`/`*alias` document.

`credential_shape_errors` also gains the per-server and per-ELEMENT rows for
`mcp_servers`, mirroring what `env_file` already had. The element row is the one
that matters — an `env_vars` entry smuggled in as a mapping was the single most
dangerous shape in the block and was unnamed. Note this makes the write path
(`generate_credential_files` → 400) reject a template that previously created an
agent with a garbage declaration: correct per PR-A's fail-loud write contract, and
release-noted.

`generate_credential_files` is deliberately UNTOUCHED — it still reads `env_file`
names-only, which is what makes the forward-compatibility argument true rather
than asserted.

Refs Abilityai/trinity-enterprise#128

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
AndriiPasternak31 pushed a commit that referenced this pull request Aug 2, 2026
…ial paths from name: (#1900) (#1935)

* fix(templates): contain local: template id resolution on the read path (#1900)

`GET /api/templates/{template_id:path}` handed `local:<name>` straight to
`get_local_template`, which joined `<name>` onto the templates root with no
validation. The `:path` converter permits `/`, so `local:../<x>`,
`local:/<abs>/<x>` and a root-escaping symlink each read
`<escaped-dir>/template.yaml` and echoed its contents in an authenticated 200.

Reachable by any authenticated principal of any role — including an
agent-scoped MCP key, so a prompt-injected agent qualifies. In a container the
reachable set includes `/data/deployed-templates/<victim>`, where every user's
uploaded template archive lands: a cross-tenant read.

Two corrections to the issue's framing, both verified here:
  * it is NOT arbitrary file read — the filename is fixed (`template.yaml`), it
    must parse as a YAML mapping, and only a fixed key set is echoed. But those
    keys' VALUES are arbitrary YAML subtrees, not just strings.
  * `local:..` alone is not an existence oracle: a directory with no
    `template.yaml` returns the same 404 as an unknown id.

Fix: `contained_template_dir(name, root)` — the two-step barrier the CREATE
path has had since #950 (`crud._safe_local_template_path`), brought to the read
path. A name allowlist runs BEFORE any path math (this is also what CodeQL
recognises as a `py/path-injection` barrier; resolve-only was flagged
high-severity twice on this codebase), then `resolve()` on BOTH sides plus
`is_relative_to`. `str.startswith` is not equivalent — it passes the sibling
escape `<root>-evil`.

An escaping id returns `None`, so the router's 404 stays byte-identical to an
unknown template: no error code, no path, no root name. A distinct error would
be a NEW enumeration oracle, which is what #1759's single-sentence 404 exists
to close. Rejections log at DEBUG, sanitized — the endpoint has no rate limit,
so a per-rejection WARNING would be an authenticated log-flood primitive.

The helper is public: the remote-template-registry work (trinity-enterprise#14)
edits this same resolver family in this same module and should import it rather
than copy it.

Tests: every rejection test PLANTS a real `template.yaml` at the escaped
location, because unpatched code returns `None` for any id whose target simply
does not exist — a rejection test with nothing planted is green before and
after and proves nothing. Each is labelled REPRO (verified red pre-fix) or
HYGIENE (cannot be made red; contract only). The router guard lives in
`tests/unit/` because no gating CI job collects `tests/test_templates.py`.
`test_1900_containment_survives_a_symlinked_root` is the landmine guard for
resolving both sides: a half-resolved variant passes all 130 pre-existing tests
and only that test catches it.

Refs #1900

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(templates): stage .mcp.json from the validated template dir, not template name: (#1900)

The second traversal sink, found by this issue's own AC #4 audit ("audit the
by-name create path for the same join"). It is NOT the create path's `local:`
id resolution — that has been contained since #950/#1759 via
`crud._safe_local_template_path`, applied at both seams #1759 named. It is the
create path's CREDENTIAL STAGING, which threw that validated path away and
re-derived a directory from scratch:

    template_name = template_data.get("name", "")          # untrusted
    mcp_template_path = templates_dir / template_name / ".mcp.json"

`name:` comes from an uploaded template.yaml, so any `creator` reaches it via
`deploy_local_agent`. `name: ../../data/deployed-templates/<victim>` read
another tenant's `.mcp.json` — a credential-bearing file type under
Invariant #12 — into the attacker's OWN agent, where they read it at leisure.
A victim who hardcoded a token rather than a `${VAR}` placeholder leaks it.

Assessed on its own axes, NOT inherited from the read sink: different trigger
(`creator` role + an upload + an agent create, vs a bare authenticated GET) and
a higher impact ceiling (credential values, not template metadata). Also P2, for
different reasons.

The derivation was also simply wrong. `name:` is not a directory name — 5
shipped templates declare a display string there ("Test Echo Agent"), so
`local:test-echo` resolved to `<curated>/Test Echo Agent/.mcp.json`, which does
not exist. That kills the "validate the name" framing: the value should not
resolve paths at all.

Fix is root-cause, not another guard: `_stage_config_files` already calls
`_safe_local_template_path` itself for the `/template` bind decision, so the
validated directory is available in the same function. Extract the two-root
ladder as `_resolve_local_template_dir` and pass its result as
`template_base_path`. The untrusted join is gone from the live path, and the
#1759 "seams must agree" property becomes structural across all THREE seams
(resolver, bind decision, credential stager) instead of two.

Deliberately NOT threaded through `_TemplateResolution` or the return tuple:
`_resolve_local_template` returns a 2-tuple that three existing tests depend
on, two as monkeypatched `lambda config: ({}, None)` doubles — widening it
breaks the test doubles, not just the callers. Its signature, its return arity,
`_safe_local_template_path`, `_LOCAL_TEMPLATE_ROOTS`, and everything inside the
CodeQL-sensitive `if template_yaml.exists():` block are untouched (#1793 had to
revert exactly that reshape).

The residual `template_base_path is None` arm is kept and made fail-closed: it
is a public function with a `template_base_path=None` default, so a future
caller can still reach it. It now contains through the same barrier as the id,
which also absorbs a non-string `name:` — `Path(root) / 123` raised TypeError,
i.e. an uncaught HTTP 500 during agent creation (the ent#128 bug class, one
seam over).

One disclosed behaviour change: a deploy-local template that BOTH declares
`credentials.mcp_servers` AND ships a `.mcp.json` now gets `${VAR}`
substitution, where the old curated-root lookup always missed. Verified no
collision with `deploy._prepopulate_workspace_from_template`, which writes the
archive's raw copy into the workspace volume: `startup.sh` copies
`/generated-creds/.mcp.json` unconditionally (gated only on the directory
existing) and AFTER the template-copy block (gated on `.trinity-initialized`),
so the substituted file deterministically wins — which is the intended
behaviour, the raw copy still carrying unsubstituted placeholders. Not one of
the 26 shipped curated templates contains a `.mcp.json`, so the curated rows
are provably unchanged.

The crud seam tests are mandatory, not decorative: every service-level test
calls `generate_credential_files` directly, so an "extracted but never wired"
mistake leaves all of them green while deploy-local resolution silently
regresses into the fallback arm.

Refs #1900

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: record the #1900 containment contracts for template id + credential staging

Rule #1 (requirements before implementation) — `core-agent.md`:

* §4.1 gains the **read-path resolution contract** as a sibling to the existing
  create-time contract: `GET /api/templates/{id}` resolves `local:<name>`
  through the same two-step barrier the create path has had since #950, and a
  failing name returns a 404 byte-identical to an unknown template (the #1759
  non-disclosure rule). Records the deliberate, known asymmetry that
  `get_local_templates()` still enumerates by `iterdir()` and so could LIST a
  root-escaping symlink that detail and create both refuse — the listing is the
  outlier, and planting one needs local filesystem write access, not a request.

* §4.3 records that the `credentials.mcp_servers` template lookup now resolves
  from the validated path rather than the template's own untrusted `name:`
  field, including the one disclosed behaviour change (deploy-local templates
  now get `${VAR}` substitution) and why the substituted file wins over the
  archive's raw copy.

`architecture.md` gets one clause on the `templates.py` router catalog entry
(the catalog rule caps entries at 2 lines, and no Cross-Cutting Subsystems
block is warranted). A bug fix would normally be commit-message-only under the
tiered-docs rule; the exception is that this ships a public, importable
containment primitive in the exact module and resolver family the remote
template registry (trinity-enterprise#14) will edit, and one catalog clause is
the cheapest way that author finds it instead of copying the flaw.

Not updated, deliberately: no feature-flow doc (no new vertical slice), no user
docs (no user-visible change for honest callers), no schema/migration (no DB
change, so the dual-track SQLite/Alembic rule does not apply).

Refs #1900

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(feature-flows): sync template-processing + local-agent-deploy for #1900

`/sync-feature-flows` was NOT a no-op here — three concrete staleness points in
`template-processing.md`, which owns the `local:` resolution surface:

* The inlined two-root ladder is now `_resolve_local_template_dir`; the code
  block showed the pre-extraction form.
* "**Two** seams read `_LOCAL_TEMPLATE_ROOTS` and must stay in agreement" was
  the #1759 claim and is now wrong in the direction that matters: there was
  always a third seam (the credential-file stager) which did NOT agree — it
  re-derived the directory from the template's untrusted `name:`. Corrected to
  three, with the extraction as the structural guarantee.
* `generate_credential_files` was cited by stale line range (`:228-299`) and
  documented none of where the `.mcp.json` template is actually located.
  Replaced the fragile line-range citation with a symbol reference and added
  the provenance, the residual fail-closed arm, and the disclosed deploy-local
  substitution delta.

Also documents the read-path containment (`get_local_template` →
`contained_template_dir`) beside the existing #1513 catalog-curation note,
including the deliberate list-vs-detail asymmetry for a planted symlink.

`local-agent-deploy.md` gets one line at the credential-merge step: a
deploy-local template's `.mcp.json` now resolves from the deploy-local
directory, so a hostile `name:` cannot read another tenant's file and
`${VAR}` substitution finally applies to that template.

`credential-injection.md` was checked and NOT touched — its `.mcp.json`
references are unrelated (credential inject/export/import), and the template
lookup lives in template-processing.

One dated row added to the feature-flows index (no new flow document — this is
a fix to documented behaviour, not a new vertical slice).

Tests: adds the end-to-end `test_get_template_rejects_path_traversal` beside
the existing 404 test. Stated plainly in the test's own docstring that this
file is root-level and collected by NO gating CI job — it runs under
`/verify-local` only, so it must not be read as CI coverage. The CI-gated guard
is `tests/unit/test_1900_template_id_traversal.py`. The multi-level case is
percent-encoded because httpx collapses a literal `../../` client-side.

Refs #1900

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(docs,templates): state the #1900 credential-staging delta honestly (review)

/review + /cso on the #1900 branch. One MEDIUM finding, verified by execution
rather than by reading, plus two comment-accuracy defects.

MEDIUM — the disclosed behaviour delta was described as a gain and is a loss.

Three docs claimed a deploy-local template "now genuinely receives `${VAR}`
substitution". It does not. `generate_credential_files` has exactly one
production caller (`crud._stage_config_files`, verified by grep across src/),
and it passes `agent_credentials={}` — CRED-002 injects real values AFTER
creation, not at staging. So `agent_credentials.get(var_name, "")` rewrites
every placeholder to the empty string. Measured against the shipped code:

    archive .mcp.json : {"env": {"TOKEN": "${MY_TOKEN}", "FIXED": "literal"},
                         "args": ["-y", "${MY_TOKEN}"]}
    staged .mcp.json  : {"env": {"TOKEN": "",           "FIXED": "literal"},
                         "args": ["-y", ""]}

and the staged file WINS over the archive's raw copy, for exactly the
`startup.sh` ordering reason the branch already documents. Net effect for a
deploy-local template shipping a `.mcp.json`: its placeholders are destroyed,
and nothing is substituted in. Non-placeholder content survives verbatim.

This is not a reason to revert the root-cause threading — the `.env` arm of the
same function has always blanked an un-supplied `credentials.env_file`
variable, so blank-at-staging is the platform's model, and `.mcp.json.template`
(compatibility check S-009) remains the durable record of required variables,
pre-populated untouched. It IS a reason to stop advertising it as an
improvement: prose stronger than the code is itself a defect (learnings
2026-07-28), and this one would have shipped into the requirements file.

Corrected in requirements §4.3, template-processing.md and local-agent-deploy.md,
and pinned by a new test so the flattering restatement cannot come back:
`test_1900_staging_with_an_empty_credential_map_blanks_placeholders` asserts the
measured output (`""`, `["-y", ""]`, `${MY_TOKEN}` absent) and that hardcoded
entries are preserved.

LOW — `_LOCAL_TEMPLATE_ROOTS`' own comment still said "Read at TWO seams",
which is the pre-#1900 claim and stale in the direction that matters: this PR
exists because the third seam did not agree. Corrected to three, naming the
extraction as what makes the agreement structural.

LOW — "crud -> template_service is forbidden" (helper docstring + parity-test
docstring) contradicts crud.py:32, which imports this module today. The ban is
on what may be GATED on it (the #1484 MagicMock harness), not on the import
edge; a reader who checks the citation and finds it false is one step from
"helpfully" importing the regex. Narrowed to say so.

Verification performed for this review, beyond the above:
* every REPRO test re-run against reverted sources — 19 of 30 red pre-fix; the
  11 that are green pre-fix are all correctly labelled HYGIENE /
  anti-over-block / landmine-guard, so no test is decorative;
* `test_1900_containment_survives_a_symlinked_root` re-proved as the sole guard
  for resolving both sides (a candidate-only-resolve variant fails it and
  nothing else);
* independent attack harness, 55 hostile inputs through `contained_template_dir`
  and `get_local_template`: zero escapes, zero raises, zero disclosures, and
  every legitimate name still accepted. The only non-None hostile input is the
  documented `"sage\n"` `$`-parity edge, which is CONTAINED;
* symlink loops, a 200 KB name and a regex-backtracking probe: no exception, no
  superlinear time (the endpoint is unauthenticated-adjacent and rate-limitless);
* `startup.sh` re-verified independently: `/generated-creds/.mcp.json` is copied
  at :376-383, gated only on the directory existing, AFTER the
  `.trinity-initialized`-gated template blocks that end at :367.

Refs #1900

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(tests): state honestly that test_templates.py has no automated runner

The docstring claimed the #1900 traversal assertions "run under
/verify-local". They do not. Every automated stage collects a
subdirectory -- CI runs `pytest unit/`, /verify-local runs `pytest unit/`
then `pytest integration/` -- and this file is root-level, so NEITHER
collects it. The assertions have no automated runner at all.

Corrected to say so, with the manual invocation that does exercise them
(needs a booted backend), a pointer to the CI-gated guard that actually
protects the fix, and a note that giving this file a runner is a
follow-up. Docstring only -- no assertion changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Eugene Vyborov <eugene@beingluminous.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
AndriiPasternak31 pushed a commit that referenced this pull request Aug 2, 2026
…ange (#1931)

Rule #1: land the requirements/flow delta before the code.

requirements/core-agent.md
- §4.1: two bullets — catalog intent is DECLARED, not defaulted (every bundled
  directory must set `hidden:`; the runtime default is deliberately unchanged,
  because flipping it turns a forgotten key into a silent absence); and demo
  fleets ship hidden but stay deployable via a bundled manifest, with the
  system-name/short-name coupling to dd-lead's hardcoded roster spelled out.
- §4.2.1: the shipped GitHub default list is empty and why; None-vs-[] now
  differ only in the `source` badge; `github:owner/repo` create is untouched.
- §4.5: the GitHub-zero empty state — marketplace-first, then the owner/repo
  CTA, then the role-branched curation hint; plus the precedence rule that
  makes it mutually exclusive with the page-level empty state.

feature-flows
- library-page.md: the GitHub-zero placeholder, the 4-row precedence truth
  table, and a #1931 revision-history row.
- template-processing.md: the sort no longer "orders starters ahead of the
  rest" — after this change there is no rest; component is Library.vue.
- mcp-orchestration.md: drop the agent-ruby example that will never again
  appear in list_templates.

No architecture.md change: it documents the mechanism (the `hidden:` filter,
the None-vs-[] fallback, the section render), and no mechanism changes here.
Adding a catalog-contents paragraph would give requirements/core-agent.md §4 a
second home, against that file's own editorial rule #1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
AndriiPasternak31 pushed a commit that referenced this pull request Aug 2, 2026
…epo list as starting points (#1934)

* docs(templates): requirements + flow delta for the catalog-honesty change (#1931)

Rule #1: land the requirements/flow delta before the code.

requirements/core-agent.md
- §4.1: two bullets — catalog intent is DECLARED, not defaulted (every bundled
  directory must set `hidden:`; the runtime default is deliberately unchanged,
  because flipping it turns a forgotten key into a silent absence); and demo
  fleets ship hidden but stay deployable via a bundled manifest, with the
  system-name/short-name coupling to dd-lead's hardcoded roster spelled out.
- §4.2.1: the shipped GitHub default list is empty and why; None-vs-[] now
  differ only in the `source` badge; `github:owner/repo` create is untouched.
- §4.5: the GitHub-zero empty state — marketplace-first, then the owner/repo
  CTA, then the role-branched curation hint; plus the precedence rule that
  makes it mutually exclusive with the page-level empty state.

feature-flows
- library-page.md: the GitHub-zero placeholder, the 4-row precedence truth
  table, and a #1931 revision-history row.
- template-processing.md: the sort no longer "orders starters ahead of the
  rest" — after this change there is no rest; component is Library.vue.
- mcp-orchestration.md: drop the agent-ruby example that will never again
  appear in list_templates.

No architecture.md change: it documents the mechanism (the `hidden:` filter,
the None-vs-[] fallback, the section render), and no mechanism changes here.
Adding a catalog-contents paragraph would give requirements/core-agent.md §4 a
second home, against that file's own editorial rule #1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(templates): hide the dd-* demo fleet, empty the stale GitHub default list (#1931)

A fresh install's Library showed 14 local templates — 11 of them the VC
due-diligence demo fleet — plus 6 GitHub repos last pushed Dec-2025/Jan-2026
that no install had ever overridden. The visible catalog is now the 3 starters
we actually stand behind.

  get_local_templates() on the real catalog: 14 -> 3 (sage, scout, scribe)
  GitHub entries on a default install:        6 -> 0
  bundled dirs omitting `hidden:`:           14 -> 0
  bundled dirs total:                        25 -> 25  (nothing deleted)

Catalog
- 11 x `hidden: true` on dd-*/template.yaml, placed and commented to match the
  11 directories that already declare it. Not deleted, not moved: it is a demo
  we still run, `local:dd-lead` stays creatable by id, and the resolver never
  reads `hidden` (verified: get_local_template("local:dd-lead") still resolves).
- 3 x `hidden: false` on sage/scout/scribe — the AC asks a new directory to
  DECLARE its catalog intent, so the declaration has to be mandatory.

- DEFAULT_GITHUB_TEMPLATE_REPOS = []. Every consumer already tolerates it (five
  existing unit tests stub exactly this), and the one path that could have
  regressed does not: recreating an agent made from `github:abilityai/agent-ruby`
  routes through get_github_template, whose `if repo in DEFAULT_...` branch and
  "Dynamic" fallback are byte-identical two-line bodies. Zero behavioural delta.
  Emptying a BROWSE list deletes no data and stops no agent, so the #1638
  "mutable code default read at action time" lesson does not bite here.
  Side-effect, intended: GET /api/templates now makes zero outbound GitHub calls
  on a cold metadata cache, where it previously blocked on up to six.

Still deployable as a set
- config/manifests/vc-due-diligence.yaml, PROMOTED from
  docs/demos/vc-due-diligence/system-manifest.yaml — not authored. The system
  name and short names are load-bearing: deployed names are f"{name}-{short}"
  and dd-lead/CLAUDE.md hardcodes its roster as `vc-due-diligence-dd-*`, so a
  tidier `vc-demo` + `founder` would deploy 11 healthy containers whose Deal
  Lead reaches nobody. Added the nine dd-lead -> specialist permissions (the
  manual post-deploy step the old copy told you to run by hand); dropped
  `prompt:` (overwrites trinity_prompt), `auto_start:`/`folders:` (not read by
  parse_manifest) and per-agent `resources:` (each dd-* template.yaml overwrites
  it at creation). dd-lead is listed FIRST so a creator hitting the default
  10-agent quota loses a specialist, not the orchestrator.
- The old copy gets a SUPERSEDED banner and the demo README points at the new
  location; keeping it (rather than moving) preserves any existing link, and the
  banner is what stops the two drifting.

Tests
- test_1931_catalog_intent.py (new, dependency-free): every bundled directory
  declares `hidden:`, it is a real bool, and the visible set is pinned to
  sage/scout/scribe. Own file so `import yaml` stays out of
  test_local_templates_listing.py's import block, and because
  _build_local_template's `bool(data.get("hidden", False))` destroys exactly the
  present-vs-absent information this asserts on. The RUNTIME default stays
  visible-by-default on purpose — flipping it turns a forgotten key into a
  silent absence, which is the worse failure.
- test_1931_manifest_roster.py (new): validate_manifest over the glob, plus the
  assertion that would have caught the naming trap. It anchors on the SHORT NAME
  (`<prefix>-<short>` in a deployed template's CLAUDE.md must equal that
  manifest's resolved name), not on the manifest's own name — the latter goes
  vacuously green on precisely the rename it exists to catch. Verified to fire
  on both a system rename and a short-name rename, and to find zero false
  positives across all four bundled manifests.
- test_local_templates_listing.py: `dd-` joins the visible-prefix ban;
  test_real_catalog_surfaces_starters_ahead_of_suite renamed and reworked — its
  `if dd_positions:` clause could now only go vacuous, so it asserts the
  priority: 20 mechanism instead, with the ordering clause generalised and
  labelled inert.
- test_ent124_default_system_seed.py: the two on-disk checks widened from
  default-system.yaml to a glob over config/manifests/*.yaml, parametrised so a
  failure names the manifest. They are properties of any bundled manifest.

Docs
- config/agent-templates/README.md: the dd table moves out of "Starter
  templates" into its own "Demo fleet" subsection under "Not starting points",
  both demo manifests are named, and the authoring rule becomes the
  intent-declaration contract.
- mcp-orchestration.md + mcp-server create_agent description (Invariant #13):
  drop the `github:abilityai/agent-ruby` example that will never again appear
  in list_templates.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(library): teach the next action when there are no GitHub templates (#1931)

With DEFAULT_GITHUB_TEMPLATE_REPOS emptied, a default install has zero GitHub
templates — and the section was wrapped in `v-if="githubTemplates.length > 0"`,
so it silently VANISHED. requirements §4.5 promises "per-kind empty states
teach the next action"; that promise was being honoured only by accident,
because the count had never been zero before.

Library.vue
- New `noTemplatesAtAll` computed over the WHOLE /api/templates response (both
  sources). The section now renders on `!noTemplatesAtAll` — not
  `githubTemplates.length > 0 || !noTemplatesAtAll`, whose first disjunct is
  provably dead since githubTemplates is a filter over templates. The
  page-level "No templates configured" block is UNCHANGED and keeps sole
  ownership of the wholly-empty case; the 4-row truth table is inlined at the
  computed, and exactly one empty state renders in every row.
- Placeholder card, marketplace-first (operator decision): the
  abilityai/abilities marketplace + create-agent wizards lead, because they
  exist today and a fresh install's Settings panel does not. Then the
  secondary "already have a repository?" action, then the curation hint.
- The CTA is `useTemplate({ id: 'github-custom' })`, CreateAgentModal's own
  sentinel for the free-form owner/repo option — deliberately NOT
  `useTemplate(null)`, which is byte-identically the Blank Agent button two
  sections up and would land the user on the wrong option under a different
  label. The sentinel arrives via the existing `initial-template` prop and is
  explicitly exempted from that component's unknown-template reset, so no
  CreateAgentModal change is needed.
- Only the curation hint branches on role (`useRole()`), mirroring
  LibrarySkillsSection.vue on this same page — the templates half must not
  ship the opposite convention to the skills half. The ACTION is offered to
  both roles so a non-admin is never left holding only an admin-only path.
- Tag-along: the page-level hint said "Configure GitHub templates in config.py",
  which is not an operator surface and is now empty by design.

Settings.vue — the destination must not dead-end
  The Library's own hint sends an admin to Settings → GitHub Templates, where a
  fresh install said "…or reset to defaults" next to a Reset button that is
  :disabled in exactly that state and would now reset to the same empty list.
  This change would otherwise satisfy the AC on the Library and newly violate
  it one click away. Two strings: drop the impossible action from the empty
  row, and stop badging an empty set as "Using defaults".

Verified: `npm run build` clean. No e2e spec asserts any changed string —
smoke.spec.js matches the headings 'Library' / 'Agent Templates' with
exact: true (the new card's heading is "No GitHub templates configured"), and
settings-tabs.spec.js only route-mocks /api/settings/github-templates.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(templates): prove the empty GitHub default list is a zero-delta change (#1931)

/update-tests + /sync-feature-flows tail pass.

The riskiest claim in this change was verified only by READING: that emptying
DEFAULT_GITHUB_TEMPLATE_REPOS cannot break an existing agent created from
`github:abilityai/agent-ruby`, because get_github_template's `if repo in
DEFAULT_...` branch and its "Dynamic" fallback are byte-identical two-line
bodies. That is the sibling-path rule — a guard proven on one codepath has to
be proven on every path reaching the same behaviour — so it is now proven by
RUNNING:

  test_1931_empty_github_defaults.py
  - an unconfigured github: id still resolves with the list empty
  - the emptied and configured paths return the IDENTICAL template dict
  - get_all_templates() with [] makes ZERO outbound metadata fetches
    (the intended side-effect: no ThreadPoolExecutor, no HTTP, no PAT read)
  - counter-test: a CONFIGURED repo is still fetched, so the assertion above
    cannot go vacuously green if fetching breaks entirely
  - the shipped constant is []

Verified to fail when the list is refilled. `tests/lint_sys_modules.py` green
(monkeypatch.setitem, no bare sys.modules assignment); order-independent
(_metadata_cache cleared per load, since it is module-global and survives).

Flow docs — two surfaces the plan had not named:
- platform-settings.md (TMPL-001): the section said admins configure repos
  "replacing the hardcoded config.py list" and that no-DB-config falls back to
  "[...from config.py...]". Both now misleading. Records the empty default,
  that None and [] produce the same catalog and differ only in the badge, that
  Reset-to-Defaults reverts to empty (and is already :disabled there), that
  create capability is untouched, and the zero-outbound-calls side-effect.
- system-manifest.md: a new bundled manifest is DATA, NOT A TRIGGER
  (BUNDLED_MANIFEST_PATH is hard-coded to default-system.yaml; nothing globs
  the directory) — the basis on which an 11-agent / ~40 GB manifest was safe to
  add. Plus the ent124 glob widening and the new roster guard.
- feature-flows.md: one Recent Updates row.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(templates): scan every prompt surface for a hardcoded roster (#1931)

Review findings on this branch's own new tests.

1. The roster guard walked `CLAUDE.md` only. A literal collaborator name is
   just as load-bearing — and breaks just as silently — in a slash command,
   and the sibling ent#239 check already treats `.claude/commands/` as a
   first-class shipped surface. Restricting the walk left four real literals
   unguarded in the bundled corpus:
     sage/.claude/commands/request-research.md          -> acme-scout
     demo-analyst/.claude/commands/briefing.md          -> research-network-researcher
     demo-analyst/.claude/commands/request-research.md  -> research-network-researcher
   Widened to CLAUDE.md + .claude/commands/*.md + .claude/skills/**/*.md via a
   deduped, sorted `_prompt_files()` helper. Token pattern unchanged.
   Corpus: 13 -> 28 matching tokens across 4 manifests, zero offenders.
   Mutation-proved twice: renaming the manifest `name:` goes red (as before),
   and breaking a name inside a .claude/commands file now goes red too — the
   coverage that did not exist before.

2. `test_shipped_default_is_empty` did a bare `sys.path.insert(0, ...)` in the
   test body: a permanent, un-undone global side-effect (one duplicate entry
   per session) that `tests/unit/conftest.py` already makes unnecessary.
   Dropped; the test still passes standalone.

3. Dropped an unused `import yaml` inside the roster test, and corrected a
   comment claiming `_metadata_cache` "survives across loads" — each
   `exec_module` builds a fresh module, so the `.clear()` is defensive only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(test): correct the unguarded-literal count to three (#1931)

The `test_manifest_resolved_names_satisfy_hardcoded_rosters` docstring said
restricting the walk to `CLAUDE.md` left "four" real literals unguarded. It is
three. Verified twice:

    grep -rlE "acme-scout|research-network-researcher" config/agent-templates/

returns 6 files, of which exactly 3 are not `CLAUDE.md` —
`demo-analyst/.claude/commands/{briefing,request-research}.md` and
`sage/.claude/commands/request-research.md` — and an occurrence count confirms
one match per file. The parenthetical that follows already enumerated three;
only the count word was wrong.

Docstring-only. No assertion, scan surface, or behaviour changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(secret-scan): allowlist the sanctioned YOUR_TOKEN placeholder (#1931)

gitleaks' default `curl-auth-header` rule flags the
`-H "Authorization: Bearer YOUR_TOKEN"` line in the DEPLOY header comment of
config/manifests/vc-due-diligence.yaml (entropy 3.121928). It is a
documentation placeholder, not a credential, and is verbatim-identical to the
pre-existing line in config/manifests/research-network.yaml:10 — that one never
tripped the scanner only because the workflow scans the PR commit range, not the
whole tree. So this PR followed the house convention rather than introducing a
new practice; the manifest comment is left untouched.

Add YOUR_TOKEN to the existing `regexes` allowlist beside the other
CLAUDE.md-sanctioned placeholders (`your-api-key`, `your-domain.com`).
Deliberately NOT a `paths` entry: per the note already in this file, a path
allowlist is a PRE-SCAN file skip and would stop config/manifests/ being scanned
for real secrets, whereas a targeted regex suppresses only this placeholder.

Verified locally with the CI-pinned gitleaks 8.30.1 over the same commit range:
1 leak before, "no leaks found" after; a planted ghp_ PAT under
config/manifests/ is still detected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Eugene Vyborov <eugene@beingluminous.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
AndriiPasternak31 pushed a commit that referenced this pull request Aug 2, 2026
…ed templates (#1908) (#1936)

* docs(requirements): bundled-template .gitignore hygiene contract (#1908)

Rule #1 — requirements before implementation. Trinity grades every agent
against docs/agent-validation-spec.md but shipped no gate on the templates
it ships itself: all 14 visible bundled templates failed the same four HARD
security checks at birth (S-001 .env, S-002 .mcp.json, S-004
.claude/projects/, S-005 .trinity/).

Adds core-agent.md §4.1.1 stating the contract, the canonical-list
provenance, the G-001 trap (no wholesale .claude/ exclusion), the
second-order effect on this repo's own view of config/agent-templates/,
the .trinity/* escape hatch for a future template shipping committed hooks,
and two honest gaps: hiding a template does not stop it birthing findings
(the resolver never reads `hidden`), and T-004/T-005 remain for the three
starters.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(templates): ship the canonical .gitignore in all 14 visible bundled templates (#1908)

An agent created from `sage`, `scout` or `scribe` was born with four HARD
compatibility findings and no user changes: the templates shipped no
.gitignore at all. The 11 visible `dd-*` templates shipped one, but its
entire content was `outputs/` + `*.log` — covering none of `.env`,
`.mcp.json`, `.claude/projects/` or `.trinity/`. So 14 of 14 visible
templates failed the same four checks, and hiding a template does not fix
it: the resolver never reads `hidden`, so a hidden template stays creatable
by id and births the same findings.

`_GITIGNORE_PATTERNS` was already merged into every agent's .gitignore, but
only at git init / first Push — too late for the first compatibility report
and never at all for a `local:` agent that never syncs. Shipping the same
list in the template moves protection from first sync to first boot. It is
byte-identical to what the sync merge and the #668 auto-fix would write, so
birth-state == post-auto-fix state == post-sync state.

Content is derived mechanically from the fenced gitignore block in
docs/TRINITY_COMPATIBLE_AGENT_GUIDE.md (itself parity-tested against the
constant), never hand-typed. Each `dd-*` keeps its own `outputs/` rule in a
trailing `# Template-specific` section, so this augments those files rather
than clobbering their intent. No bundled template gains a bare `.claude/`
line — that is HARD check G-001, and it would trade four findings for one.

Measured with the real STATIC_CHECKS registry over a collector-shaped
snapshot: the 11 `dd-*` go 4 HARD -> 0; the 3 starters go 6 HARD -> 2, the
residual being T-004/T-005 (`resources.cpu`/`resources.memory` absent from
template.yaml). Those are deliberately not fixed here — a template-level
`resources` block overrides the admin's fleet-wide default (RES-001), so
pin-vs-inherit is a product decision. AC#2 ("0 hard findings") is therefore
met for 11 of 14 templates and not for the 3 starters.

tests/unit/test_1908_bundled_template_gitignore.py is both the guard and
the regenerator (`--regenerate`), so a new canonical entry is a one-command
change however many templates are guarded. It evaluates the real
`static_checks.run_static` rather than re-implementing the rules, asserts
`status == "pass"` positively plus set-equality with the requested ids (a
renamed id returns "skipped", so "nothing failed" would empty itself),
pins the one legitimate precondition skip (D-003, no dashboard.yaml),
fails if a new *visible* template is added outside GUARDED_TEMPLATES, and
applies G-001 to every bundled directory including hidden ones. Verified
non-decorative by mutation: appending `.claude/` to sage fails 5 tests and
deleting its .gitignore fails 6.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(templates): close the doc->template .gitignore seam in the #1908 guard (#1908)

The regenerator builds all 14 bundled `.gitignore` files by copying the
```gitignore``` fence out of `docs/TRINITY_COMPATIBLE_AGENT_GUIDE.md` verbatim,
but nothing asserted that direction. `test_doc_and_constant_in_sync` checks only
`_GITIGNORE_PATTERNS ⊆ doc`, and the two guards added in the previous commit are
one-way too (`canonical ⊆ shipped`, then byte-identity *with the regenerator*).
So any line added to that doc fence shipped, unreviewed and untested, into every
bundled template — and thence into every new agent's own repository, where the
sync-time `git rm --cached` sweep would untrack whatever it newly matched.

Proven open, not assumed: injecting `skills/` into the fence and re-running the
regenerator left `test_guarded_gitignore_mirrors_canonical_patterns` and
`test_guarded_gitignore_matches_the_regenerator` both GREEN.

- `ALLOWED_NON_CANONICAL` pins the delta that exists today (`!.env.example`,
  `!.mcp.json.template` — the guide's fence is a superset of the constant by
  exactly these two negations) with the reason each is admissible.
- `test_guarded_gitignore_ships_no_unreviewed_pattern` closes the direction:
  a shipped line must be canonical, pinned, or that template's own
  `TEMPLATE_EXTRAS`. It REDs on the `skills/` injection above.
- `test_allowed_non_canonical_is_not_stale` retires the pin if the guide drops
  an entry, so it can never become a standing licence.

Also corrects three claims that did not hold:
- `_EXPECTED_SKIPS`'s "no bundled template ships a dashboard.yaml" —
  `trinity-system` does; it is simply hidden and unguarded.
- the module docstring's implication that the guide block is a faithful mirror
  of `_GITIGNORE_PATTERNS` (the parity test is one-way).
- requirements §4.1.1's "byte-identical to what the sync merge and the #668
  auto-fix would write" — both are append-if-missing, so the accurate and
  stronger statement is that they are *no-ops* on this file.

Guard 78 -> 93 passed; full `tests/unit -m "not slow"` 6086 passed / 18 skipped
(baseline 6071, delta = the 15 new cases). Regenerator still idempotent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Eugene Vyborov <eugene@beingluminous.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
AndriiPasternak31 added a commit that referenced this pull request Aug 2, 2026
…metadata

Closes ent#128 AC #1-2. A template can now describe each credential an operator
must supply — title, description, required, secret, format, setup_url, default —
and `template_service` surfaces the normalized result as `credential_requirements`
on every catalog entry.

**Enrichment lives in a NEW sibling top-level key; `credentials:` is FROZEN as
names-only, forever.** An already-deployed older Trinity reads `env_file` through
`credential_env_file_names` and then does `agent_credentials.get(var_name, "")` —
hand it a list of mappings and that is `TypeError: unhashable type: 'dict'` at the
moment it writes the agent's `.env`. A sibling key is structurally invisible to
that binary, so there is no floor version and enrichment distributes immediately.

**Base-set-plus-overlay, so the two keys cannot drift.** One record per variable
`credentials:` declares, decorated by `credential_setup:` entries joined BY NAME.
An entry naming nothing is a named three-line error (problem, cause, FIX) and is
dropped; valid siblings survive. `credential_setup:` can only ever decorate — the
sibling-key shape's usual failure mode is closed by construction, not by
discipline. Stated honestly: for an EXTERNAL template that error is neither
impossible nor visible in the UI — `credential_errors` has zero frontend and zero
MCP consumers, so the only human channel is the backend log. It is LOGGED.

`required` is a tri-state. Enriched-and-omitted means `True` (an author who
described a variable meant it); a legacy bare `- FOO` is `"unknown"`, never `True`
— it carries no authorial intent, and reading it as required makes a guided
checklist cry wolf. `"unknown"` doubles as the enriched/un-enriched discriminator,
which is why no `enriched: false` flag is needed. `secret` defaults `True`
(fail-safe). Path-free by construction, so trinity#570's `template.yaml` →
`trinity.yaml` rename cannot reach it.

**The normalizer never raises, and that is load-bearing.** `_build_template` runs
in bare list comprehensions in `get_all_templates()`, OUTSIDE PR-A's per-template
fence (which covers `_build_local_template` only) — a raise there is HTTP 500 with
an EMPTY CATALOG, i.e. PR-A's exact bug reopened by the change that surfaces the
new metadata. And no bomb is needed: `title: 123` or a bare `title:` was enough.
So the builders ALSO wrap the call and degrade to `[]` plus a named error, rather
than fencing the comprehension — that keeps the named error the resilience
contract promises. The property does not rest on one function's discipline.
(Which earned its keep immediately: the wrapper caught a real NameError during
development instead of emptying the catalog.)

Trust boundary — `title`/`description`/`setup_url`/`name`/`source` are
author-controlled strings from arbitrary GitHub repos flowing into an
operator-facing "paste your API key" checklist:

  * **Type-guard before touching.** Never `str()` a container from untrusted YAML:
    `str()` EXPANDS a shared alias during the walk (443 B → 52 MB in 1.5 s, x10 per
    level), and both the sanitizer and the record cap act after that cost is paid.
  * **Cap the INPUT**, entries AND errors AND the base set. Capping records while
    leaving `errors` uncapped built a 35 MB response out of the cap meant to
    prevent it; and `default` had no type row, so the 100-record cap acted as a
    x100 multiplier on it.
  * **`source` is sanitized** — it carries the raw MCP server name, the exact
    string `_sanitize_for_warning`'s own docstring names as the threat, and it was
    not on the list.
  * **Per-field length caps.** Reusing the 80-char terminal-warning default
    truncated a realistic 159-char description and made a real 90-char vendor
    console URL unusable.
  * **`setup_url` above scheme-only**: https (case-insensitive — `HTTPS://` is a
    legitimate author), a parseable host, NO userinfo (`https://google.com@evil.tld`
    renders as one host and resolves to another — the display/resolve split IS the
    attack), ≤2048, printable. Validate THEN sanitize, and never through a
    truncator. Residual documented, not claimed closed: `isprintable()` rejects
    RTL/ANSI but an IDN homograph survives, so a consumer must render the parsed
    hostname beside the link.
  * **Never mutates its input** — `_metadata_cache` holds the parsed dict for 600 s
    and YAML aliases genuinely share nodes, so one in-place normalize would rewrite
    both aliased fields and persist for ten minutes. Asserted against a deep-copy
    snapshot, including a real `&anchor`/`*alias` document.

`credential_shape_errors` also gains the per-server and per-ELEMENT rows for
`mcp_servers`, mirroring what `env_file` already had. The element row is the one
that matters — an `env_vars` entry smuggled in as a mapping was the single most
dangerous shape in the block and was unnamed. Note this makes the write path
(`generate_credential_files` → 400) reject a template that previously created an
agent with a garbage declaration: correct per PR-A's fail-loud write contract, and
release-noted.

`generate_credential_files` is deliberately UNTOUCHED — it still reads `env_file`
names-only, which is what makes the forward-compatibility argument true rather
than asserted.

Refs Abilityai/trinity-enterprise#128

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
dolho added a commit that referenced this pull request Aug 3, 2026
…ent#180)

An exposed agent's card advertises every `template.yaml capabilities[]` tag, and
the well-known discovery route is unauthenticated by design — so today that full
capability list is world-readable for any exposed agent. This lets an operator
choose what the outside is told.

**A disclosure control, and the code says so.** The card's `skills[]` is
advertisement: inbound `message/send` dispatches free-form text via
`execute_task(triggered_by="a2a")`, with no per-skill routing. Filtering changes
what an orchestrator SEES, never what it may ASK for. That's stated in the
requirement, the seam docstring and the filter itself, because a filter
operators mistake for an invocation gate is a control that looks like security
and isn't. A real boundary (constraining a2a-triggered runs via
allowed_tools/guardrails) is separate work with its own threat model.

Extends the existing ent#157 seam rather than adding a module — same shape as
the inbound allow-list, second provider:

    provider.exposed_skills(agent_name) -> Optional[List[str]]

- No provider (OSS) → identity function; the card is byte-identical to before,
  by construction. The enterprise module owns the config, storage and UI.
- `None` = no opinion = advertise all: the unconfigured default, so exposure
  (already opt-in, default OFF) keeps every existing card unchanged on upgrade.
- `[]` ≠ `None`: an explicit "advertise nothing".
- Stale ids are inert — the selection only subtracts; `template.yaml` stays the
  source of truth for what exists.
- Fail-open on provider error (advertise all + WARNING): consistent with the
  seam's availability bias and the advertise-all default. Honest only *because*
  this isn't a security boundary — failing closed would silently empty a card
  and break discovery invisibly.

Both card surfaces (public well-known + authenticated per-agent) go through one
router helper, so they can't disagree and a future third surface gets the filter
by default rather than by remembering. `generate_a2a_card` stays pure — the
provider lookup lives in the helper.

Writing the tests found a real gap: a provider returning a str (a defect) would
iterate into single characters, match no id, and silently empty the card —
fail-CLOSED, the opposite of the contract, and invisible. Malformed returns now
take the same fail-open path as a raised error.

Requirements §32.4 written before the code (CLAUDE.md rule #1); public docs
describe the generic seam only, per the standing enterprise-docs rule.

Related to trinity-enterprise#180
vybe pushed a commit that referenced this pull request Aug 3, 2026
)

Trinity Rule #1 — the requirement lands before the code. Documents the
three-part shape (container config-truth probe, start-time drift predicate,
deliberate rotation), the ordering/concurrency contract (fail-closed lock,
captured-id deletion, DELETE-not-deactivate, DB-only path for stopped agents),
the allowlist auth rule, the `stale` health state, and what stays exploitable
after the change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
vybe pushed a commit that referenced this pull request Aug 3, 2026
…ership retrofit (ent#109) (#1947)

* fix(lifecycle): one owner for git env across both rebuild paths (ent#109)

`recreate_container_with_updated_config` seeds env from the OLD container and
re-derived only `GITHUB_PAT`, replaying whatever `GITHUB_REPO` / `GIT_SYNC_*`
each container happened to be carrying. The git-env derivation lived only in
`_apply_persisted_auth_env` (`recreate_missing_container`). That split is a
pre-existing fleet-wide bug, not a cosmetic one: the recreate has exactly one
production caller, `start_agent_internal`, which fires on nine config-drift
predicates AND on base-image drift at cold start — so a base-image rebuild
arms the replay for every agent at once.

`_apply_git_env_from_db` is now the single writer. Three load-bearing details:

* **The PAT gate is a parameter, never inherited.** The two paths gate
  differently on purpose. `per_agent_only` (config-drift recreate) preserves
  #211 verbatim — resolve the effective PAT only when the container already
  carries one or a per-agent PAT row exists — so a global-only platform PAT is
  never injected into a previously-tokenless container. A verbatim lift would
  have swapped that for the 2-tier per-agent -> GLOBAL resolver used by
  `effective` (the rebuild-from-nothing path, which has no old container to
  inherit a token from): `configure_push_remote` then clears the push
  blackhole and a tokenless agent can push a private KB to the shared public
  upstream. learnings.md ent#162 names this class exactly.
* **Set-or-clear**, since the recreate writes into a carried-forward dict. A
  deleted `agent_git_config` row pops the whole owned set; a `source_mode`
  flip clears the mode/branch pair. `GITHUB_PAT` alone stays set-only while a
  repo is bound — clearing it would revoke a live agent`s push on an unrelated
  recreate.
* **`GIT_SYNC_AUTO` = DB flag OR baked env**, plus a convergence backfill.
  crud.py`s two writers genuinely disagree (`and not config.ephemeral` sits
  inside a swallowing try/except on the DB side only; the column defaults to
  0), so deriving from `auto_sync_enabled` alone would silently stop auto-push
  for that slice of the fleet. The backfill writes the column the moment the
  disagreement is observed, so the OR retires itself. Making the #389 toggle
  authoritative is a separate follow-up.

ent#123 is preserved: the gate is the REPO, not the PAT, so a tokenless agent
rebuilt after container loss still clones (#843/#1439 silent-empty class).

One deliberate divergence from a verbatim lift, asserted by test: a container
with a baked `GITHUB_PAT` and NO git binding previously had that token
refreshed from the global platform PAT on every recreate; it is now popped.
The per-agent PAT is a column ON `agent_git_config`, so "no row" means no
per-agent credential and no repo to push to by construction.

Tests: tests/unit/test_ent109_git_env_seam.py — each of the four behaviours
proved to have teeth by mutation (un-gate the PAT, flip the call site to
`effective`, derive GIT_SYNC_AUTO DB-only, drop the clear sweep, drop the
source-mode clear, diverge the GIT_SYNC_AUTO literal, unguard the backfill:
all seven go red). Plus a static call-site guard, so flipping either gate
fails CI even though no behavioural test of the helper alone would catch it.

Refs Abilityai/trinity-enterprise#109 (PR 1 of 3)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(architecture): _apply_git_env_from_db owns git env on both rebuild paths (ent#109)

Adds the missing agent_service/lifecycle.py catalog entry and records the
per-call-site PAT gate, the set-or-clear contract, and the GIT_SYNC_AUTO
OR-derivation. Amends the ent#123 clause to point at the new shared seam
instead of _apply_persisted_auth_env.

Refs Abilityai/trinity-enterprise#109

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(registry): register test_ent109_git_env_seam.py

Refs Abilityai/trinity-enterprise#109

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(feature-flows): sync the git env-derivation seam (ent#109)

github-sync.md: retitle the rebuild-recovery section to
"Container-rebuild env — lifecycle.py::_apply_git_env_from_db" and document
the per-call-site PAT gate, the set-or-clear contract, the GIT_SYNC_AUTO
OR-derivation, and the two vars deliberately NOT owned.

git-sync-health.md: GIT_SYNC_AUTO is re-derived on every rebuild as
auto_sync_enabled OR the baked env (the two creation writers disagree), with a
self-retiring backfill; kill-switch row and file table corrected.

agent-lifecycle.md: Revision History row.
feature-flows.md: hand-added Recent Updates row (the skill drops it past ~400
lines). Note: that table is at 56 rows against its stated ~20 cap (#1360) —
pre-existing drift, deliberately not trimmed here.

Refs Abilityai/trinity-enterprise#109

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(#735): re-anchor the lifecycle PAT call-site guard onto _apply_git_env_from_db

ent#109 moved GITHUB_PAT derivation out of the inline block in
recreate_container_with_updated_config (which the guard anchored on via the
comment "Update GITHUB_PAT") into the shared _apply_git_env_from_db. The
guard intent is unchanged and still enforced: that block resolves the
effective per-agent PAT, never the platform-only get_github_pat().

Also fixes a silent-degradation flaw in the guard itself. str.find returns
-1 on a miss, and src[-1:-1+300] slices to an EMPTY string — so a moved
anchor made the guard assert "get_github_pat_for_agent in \x27\x27", failing with no
hint about why. The anchor is now asserted first with a message naming the
fix (re-point it, do not delete it), and the block is sliced to the next
top-level def rather than a fixed byte window.

Both failure modes proved red by mutation: swapping the helper to
get_github_pat() and renaming the anchored function.

Refs Abilityai/trinity-enterprise#109

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(lifecycle): correct-never-introduce git env; drop the auto-sync backfill (ent#109)

Two defects found reviewing the ent#109 PR 1 env seam.

1. The config-drift recreate blackholed push for agents bound post-creation.
   The repo half of the block is repo-gated (ent#123) while the PAT half keeps
   #211's narrower per-agent gate, and those two disagree for one real row
   shape: an agent bound via POST /{agent}/git/initialize on the GLOBAL
   platform PAT. That path writes an agent_git_config row and pushes, but never
   recreates the container, never bakes git env, never persists a per-agent PAT
   row, and never writes the token into the workspace .env — so its only
   credential is the one embedded in .git/config's origin URL, and startup.sh's
   #1264 fallback does not cover it. Handing startup.sh GIT_SYNC_ENABLED=true
   with no GITHUB_PAT is exactly what it reads as "deliberately tokenless": the
   restart branch rewrites origin to the credential-less CLONE_URL, destroying
   that token, and configure_push_remote blackholes the push remote — silently,
   and fleet-wide on the same base-image drift this helper exists to fix.

   `per_agent_only` now writes the block only when the old container already
   carried GITHUB_REPO or a PAT resolves. It still corrects a stale repo, a
   flipped source_mode and a deleted row — every case the fix is about; a
   tokenless ent#123 agent carries GITHUB_REPO from creation, so the flagship
   is unaffected. `effective` is exempt: with no old container, NOT introducing
   the block is the #843/#1439 silently-empty-agent bug.

2. The GIT_SYNC_AUTO backfill erased an owner's explicit disable.
   PUT /{agent}/git/auto-sync writes the row and nothing else while the agent
   gates on container env, and creation sets both true for the ordinary
   non-source-mode PAT agent — so "baked true / DB 0" is also exactly what an
   owner's disable looks like. The backfill re-enabled it on the next recreate
   and erased the only record of the intent, so the toggle could never stick.
   It was a privilege boundary too: PUT .../auto-sync is OwnedAgentByName while
   POST .../start, which triggers the recreate, is AuthorizedAgentByName — so a
   shared non-owner, or an agent-scoped key resolving to its owner with the
   owner's role (trinity-ops-agent#232), flipped an owner-only flag arming a
   15-minute background commit-and-push loop.

   The OR-derivation stays (crud.py's two creation writers genuinely disagree,
   and DB-only derivation would silently stop auto-push for that slice). The
   write-back is gone; the disagreement is logged. Making the #389 toggle
   authoritative remains the tracked follow-up that retires the OR honestly.

Tests 17 -> 22: a TestIntroduceGuard class (unbaked container untouched,
carried repo still corrected, resolvable PAT still introduces, effective
exempt, clear sweep unaffected) and the derive-only assertion. Both fixes
proved to have teeth by mutation — removing the guard and restoring the
backfill each go red on exactly one test. Two learnings.md entries.

Refs trinity-enterprise#109

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(ent#109): pin the git-env WRITER SET with an AST guard, not a source grep

The previous call-site guard sliced `lifecycle.py` by function header and
counted a single-line literal in each half. Two blind spots:

  1. It pinned only the two KNOWN sites. ent#109's bug WAS that git env had
     two writers and one of them was wrong; a THIRD writer added later on any
     container-seeded path re-opens exactly that hole, and the grep version
     stayed green through a planted `pat_gate="effective"` writer (verified by
     mutation).
  2. `lifecycle.py` names the helper in two comments, so a substring count
     read prose as call sites — the same first blind spot the #1871 guard hit.

The AST walk maps `{enclosing function: pat_gate literal}` and asserts the set
equals exactly `{recreate_container_with_updated_config: per_agent_only,
_apply_persisted_auth_env: effective}`. It also fails loud on a non-literal or
omitted `pat_gate` and on a duplicate call in one function — each of which
would make the guard silently vacuous, which is worse than the leak it guards.

Also drops the stale "convergence backfill" wording from the module docstring
and the registry entry (d8da9d08 removed the backfill; the description still
described it) and re-states the idempotence test as "the DB row is never
mutated".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: describe the ent#109 guard as a writer-set gate, not a call-site check

Follow-on to the AST guard: `architecture.md` and the `agent-lifecycle.md`
change log both said the static guard "fails CI if either call site flips",
which understates what it now enforces. It pins the whole writer SET, so a
third writer on any container-seeded path fails CI too — the property that
matters, since ent#109's bug was two writers with one of them wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(requirements): add github §11.12 post-creation repo binding (ent#109)

Trinity Rule #1 — requirements before implementation.

§11.12 specifies the "bind to your own repo" retrofit: FR-1 the explicit
supported-row table keyed on source_mode (the column the partial unique index
actually keys on) with named structural refusals for everything else, FR-2
source_mode preserved at 1 so no branch reservation is needed, FR-3 the
destination-scoped fail-closed lock + CAS + compensating restore (never
delete_git_config on a pre-existing row — that is destruction, not rollback),
FR-4 the PAT persisted last, FR-5 the mandatory recreate because startup.sh
rewrites origin unconditionally from baked env, FR-6 owner-only AND human-only
with explicit PAT disclosure, FR-7 the no_write_credentials surfaces.

Also amends §11.11 FR-5: the tokenless push refusal no longer teaches the
create-a-new-agent-and-import workaround.

Refs ent#109

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(fork-to-own): extract the shared destination primitive (ent#109 §4.5)

AC #4 asks the post-creation rebind to reuse ent#93's machinery rather than
build a parallel path. The seam is NOT the destination triage lifted whole —
that is not expressible, because the create path's reuse branch IS the
template-tip SHA comparison, interleaved with the triage in one if/elif/else.

So the seam is one level lower: inspect_or_create_destination_repo() reports
created | empty | branches and never decides. Reuse/refuse POLICY stays in
each caller, because the two callers genuinely disagree — the create path
compares against a template tip; the rebind has no template, its content
source is the agent's workspace volume, so any existing branch is a refusal.

validate_destination_pat() is a SIBLING, not folded in: the create path
validates the PAT before resolving the template tip, so 'bad PAT + unreachable
template' reports FORK_PAT_INVALID. Folding it into the inspect primitive
(which runs after the tip resolves) would silently reorder that into a
template error.

Behaviour preservation is asserted, not claimed: the 40 pre-existing
test_fork_to_own.py tests pass unchanged, and both new guards were shown to
have teeth — making the primitive refuse instead of report turns the create
path's SHA-match reuse red, and swapping the validate/resolve order turns the
ordering guard red.

Refs ent#109

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(git): bind an agent to a GitHub repo you own (ent#109)

POST /api/agents/{name}/git/bind-to-own-repo — create a user-owned repo from a
LIVE agent's current workspace, rebind origin in place, persist the per-agent
PAT, and re-bake the container env so the rebind survives a restart. Plus a
GET .../status companion so a client that eats a proxy timeout can resolve the
outcome from state rather than from a remembered request.

Shape, per requirements §11.12:

- Orchestration in services/agent_service/repo_binding.py, NOT the router
  (Invariant #1). It raises BindError and never HTTPException; the router is a
  thin mapper owning only the two locks, the idempotency claim, and the audit.
- Classification partitions on source_mode — the column the partial unique
  index actually keys on — and refuses every other shape BY NAME rather than
  mis-routing it. Credential state is an orthogonal column and is not used.
- Concurrency: a DESTINATION-scoped lock is the one that serializes the real
  collision (two different agents, one destination repo); the agent-scoped
  lock only guards double-submit. Both FAIL CLOSED with 503 + Retry-After —
  agent_data's fail-open is calibrated for a tar round-trip, not for two repo
  creates and two concurrent recreates of one container.
- The CAS in db.rebind_git_config is the whole commit point, its predicate
  named in the docstring. The loser path restores the captured previous values;
  it never calls delete_git_config, which on a pre-existing row is destruction
  (the next recreate would drop GITHUB_REPO — #843/#1439).
- The PAT is persisted LAST and strictly before the recreate: earlier makes the
  agent look already-writable on a retry, later bakes a repo-bound container
  with no token that startup.sh then blackholes.
- Post-rewire, origin is read back and confirmed — a set-url that exits 0
  without taking effect is exactly the silent mismatch AC #5 forbids.
- Owner-only AND human-only (reject_agent_principal): an agent-scoped key
  resolves to its owner carrying the owner's role, so a role gate alone is
  satisfied by any agent's injected key on a default admin-owned install.

Decision #17 (check_github_repo_env_matches) is deliberately CUT: the only
drift-proof way to build it is to call _apply_git_env_from_db, which turns PR
1's AST writer-set guard red, and idempotent retry already supplies the
convergence it was meant to buy. BIND_RECREATE_FAILED states the retry path
instead of a convergence promise, and warns against a plain restart.

Refs ent#109

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(git): cover the repo-binding commit point, ordering and secret hygiene (ent#109)

31 tests over the properties that would otherwise only be true by inspection:

- Classification: the supported shape succeeds; every other shape is refused
  BY NAME. Two cases asserted rather than argued — an already-writable agent
  is an ORDINARY rebind (the refusal that used to sit there is what made the
  documented retry unreachable), and trinity-system is refused through the
  no-git-config path so it never reaches the recreate that bypasses #1816's
  running-system gate.
- Commit point: a moved row yields 409 with nothing partial, and the
  post-commit loser is RESTORED to its captured previous values. Asserts
  delete_git_config is never called — on a pre-existing row that is
  destruction, and the row is asserted to still exist afterwards.
- Ordering: rebind -> pat -> recreate, proven by recorded call order. A push
  failure persists no PAT; a PAT-persist failure blocks the recreate; and
  fail-at-push -> retry -> success is an explicit regression test for the
  contradiction that a 409-on-retry used to produce.
- The CAS statement runs against a REAL SQLite engine, not a double — the
  predicate is the whole safety argument, so a stub cannot verify it. Includes
  two racers reading the same expected value: exactly one wins.
- Secret hygiene: the PAT is absent from the outcome, the audit dict and every
  error path, and a stale baked token in git output is redacted too.

That last group found a real defect, now fixed: repo_binding composed its
failure messages from foreign text (git output, a docker exception) and
relied on the producer having scrubbed. git_service scrubs what it reads from
a container, but the docker and GitHub exception paths arrive through
libraries that never saw the token. Added _scrub() as a belt at the boundary
where the PAT is actually in scope.

Refs ent#109

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(git): retire the no_write_credentials create-a-new-agent workaround (ent#109)

ent#230's sharpest AC, which ent#109 omitted: the no_write_credentials
surfaces must point at the retrofit once it exists. Both change together
(Invariant #13):

- git_service.NO_WRITE_CREDENTIALS_MESSAGE (consumed by sync_to_github and
  reset_to_main_preserve_state, mapped 409 in routers/git.py)
- the MCP 409 hint in src/mcp-server/src/tools/git.ts

Neither now teaches 'create a new agent with fork-to-own and import your
data' — an instruction that discards the agent's identity, its 180-day name
reservation and its history. ent#123's carve-out is preserved: this branch
still suppresses the chat_with_agent remedy, because a chat turn cannot
conjure credentials.

The third surface — startup.sh's push-remote blackhole sentinel — is
deliberately unchanged and now asserted as such: it is a git remote URL (one
shell-safe token) that already names a remedy, and editing it would force a
base-image rebuild for cosmetics.

The parity guard was teeth-checked in both directions: reverting the MCP hint
turns it red, AND breaking the source anchor turns it red with a named error
rather than silently asserting against an empty slice — the way a source-grep
guard usually dies.

Refs ent#109

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(ui): 'Bind to your own repo' panel on the Git tab (ent#109)

A new BindRepoPanel.vue mounted in GitPanel.vue rather than more markup inside
it, for two reasons: GitPanel is already 639 lines, and #1430's raw-color
ratchet is PER FILE — appending a form there would raise counts that may only
shrink. GitPanel's numbers are unchanged at 24 nongray / 146 gray; the new
panel is at ZERO raw non-gray with 51 semantic tokens (its 46 grays are the
contract's own surface/ink vocabulary — there are no Base* primitives in the
repo yet to absorb them).

Design-system contract (read first, per CLAUDE.md rule #10): semantic tokens
only (action-primary / status-success / status-warning / status-danger), both
themes first-class, gray-750 for dark chrome, and no dark:text-gray-500 —
the dark ink floor.

Behaviour worth noting:

- The store method uses raw axios with an explicit 300s timeout, following the
  surrounding idiom. It deliberately does NOT use api.js, whose instance-wide
  30s timeout is far below this call's worst case; aborting the client mid-bind
  strands the user past the commit point with no response, which is the exact
  situation the status endpoint exists to rescue rather than manufacture.
- The PAT is read out of the reactive ref BEFORE the await and cleared
  immediately, so it never lingers regardless of how the request ends.
- A client timeout is reported as PARTIAL, never as a clean failure — the
  request may well have landed.
- Post-commit failures render as 'Partly applied — action needed' in warning
  colour rather than as an error, because the binding genuinely IS saved and
  telling the user it failed would send them looking in the wrong place.
- The restart warning states what happens, what is preserved, and how long.

Both SFCs verified against the real @vue/compiler-sfc (parse + script +
template); npm run check:tokens passes.

Refs ent#109

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: architecture + feature flow for post-creation repo binding (ent#109)

- architecture.md: two endpoint rows, a Post-Creation Repo Binding subsystem
  block, the two Redis lock keyspaces, repo_binding.py + git_service's new
  primitives in the service catalog, the shared destination seam on the
  fork_to_own entry, and the ent#123 paragraph tail now that its
  no_write_credentials refusal points at the retrofit. PR 1's
  _apply_git_env_from_db prose is already present on this branch and was NOT
  re-added.
- New feature-flows/agent-repo-binding.md: the end-to-end trace, the five
  decisions that carry the design (source_mode partition, destination lock,
  CAS + restore-not-delete, PAT-last, mandatory recreate), the error registry
  with which codes are partial, the ent#93 sharing seam, security, and known
  limits — including why Decision #17's drift predicate was cut.
- feature-flows.md: Recent Updates row added BY HAND (/sync-feature-flows
  drops it past ~400 lines) plus the category-table entry.
- Cross-linked the three affected flows: github-sync.md and mcp-git-tools.md
  had the retired workaround quoted verbatim in their prose, and
  github-repo-initialization.md now names its post-creation sibling and the
  boundary between them.

Refs ent#109

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(git): cover the bind ENDPOINT surface (ent#109)

/update-tests review found the router layer uncovered — its own rule says a
new or changed endpoint needs a caller that exercises the path params and auth
dependency (#1069's 422-every-call class). 26 tests over the five things no
service-level test can see:

- reject_agent_principal really called, and wired in the handler rather than
  merely imported (an agent-scoped key resolves to its owner CARRYING the
  owner's role, so an owner/role gate alone is satisfied by any agent's
  injected key on a default admin-owned install)
- route path-param matches the handler parameter, for both routes
- locks FAIL CLOSED on a Redis outage, on a raising SETNX, and on contention;
  the destination key is case-folded; locks release on success AND failure
- idempotency key is verb-folded; absent header derives nothing; in-flight
  409; completed replay returns the snapshot with X-Idempotent-Replay
- audit on EVERY exit path incl. lock contention and the unexpected 500

Also fixes a regression this work introduced: test_ent123_tokenless_clone.py
asserted the literal retired wording of NO_WRITE_CREDENTIALS_MESSAGE, and I had
not re-run that suite after changing the shared constant. Re-anchored on the
CONSTANT plus the invariant ent#123 actually cares about (named message, still
actionable) — stronger than before, and it cannot drift again; the exact copy
is owned by test_ent109_no_write_credentials_message.py.

Both new guards mutation-verified: deleting reject_agent_principal and making
the lock fail open each turn two tests red.

Full unit suite: 6350 passed, 14 skipped, 0 failed. Identical under random and
fixed order (no sys.modules pollution across the new modules).

Refs ent#109

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(flows): /sync-feature-flows pass over the ent#109 binding surface

Verification of the hand-written docs against the code found two gaps:

- template-processing.md described the fork-to-own copy pipeline's steps 1-2
  as living inline in fork_to_own.py. They are now the SHARED half
  (validate_destination_pat + inspect_or_create_destination_repo), so a
  reader tracing the code would have found the triage in a different function
  than documented. Updated to name the seam and why it sits one level below
  the triage, with the reuse/refuse policy explicitly still owned by that
  caller. Behaviour there is unchanged.
- The new flow's error registry was missing three codes that ARE reachable on
  the bind path: FORK_DESTINATION_UNREACHABLE (shared primitive),
  BIND_DESTINATION_UNREACHABLE (fail-closed guard-read failure) and
  BIND_UNEXPECTED_ERROR (router catch-all). Verified by diffing the codes in
  the source against the codes in the doc; the seven still absent are
  create-path-only and correctly omitted.

Checked and deliberately NOT changed: git-sync-health.md and
dark-mode-theme.md reference the touched files but document nothing this PR
alters. The Recent Updates table is 66 rows against its own stated ~20 cap —
pre-existing drift (65 before this PR); trimming 46 of other people's entries
is unrelated churn on a feature PR.

Refs ent#109

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(git): converge the documented bind retry; stop a PAT leaking on rejection (ent#109)

Fixes from /review (C1, I1, I3, I6, I2) and /cso --diff (S1) on PR 2.

── /review C1: every post-commit failure promises an idempotent retry, and all
   four are refused ──────────────────────────────────────────────────────────

The CAS is the commit point, so after it the row names the destination while
the container's origin still names the old repo — and both pre-flight gates
read that skew as a refusal:

  push/rewire fail   -> row moved, origin did not -> 409 BIND_STATE_UNCLASSIFIED
  PAT/recreate fail  -> destination holds our own pushed history
                                                  -> 409 BIND_DESTINATION_EXISTS

The §0.4 class the plan wrote a section to eliminate for the PAT ordering,
re-entering through the classification guard. The vestige was in the signature:
`_classify(agent_name, destination_repo)` never used `destination_repo` — the
carve-out had been designed and not written.

A row already naming the requested destination is now a resumption:

* origin may lag — it never selects what is pushed (step 4 pushes
  refs/heads/<branch> from the workspace by explicit URL, writes origin after),
  and it cannot be tightened anyway: a committed CAS has overwritten the old
  repo name, so "still the old repo" and "something else" are
  indistinguishable, and treating the ambiguity as fatal strands the agent.
* existing branches are accepted — bounded by git, not trust: the push carries
  no --force and no `+` refspec, so unrelated history is rejected
  non-fast-forward and an unrelated branch is untouched.
* previous_repo=None on a resume leaves `upstream` alone instead of repointing
  it at the destination itself, erasing the provenance the rebind preserves.

A mismatch against any OTHER repo stays BIND_STATE_UNCLASSIFIED.

The regression test written for exactly this was green because its double
returned `origin_repo=fake_db.config.github_repo` — the container's observed
state WAS the row, so they could never disagree — and a hand-set
`dest_state = "empty"` stepped around the other gate. The fixture now tracks
the container independently and mirrors the real side effects.

── /cso S1: a GitHub PAT reaches the response body and the platform log ──────

A PAT is sent as `Authorization: Bearer <pat>`, and h11 rejects an illegal
header value by ECHOING it (verified: `LocalProtocolError: Illegal header value
b'Bearer ghp_...\r'`). The validator only checked non-emptiness and returned the
value UNSTRIPPED, so a token carrying a trailing \r or \n — what a paste from a
terminal or clipboard routinely produces — surfaced raw in a 500 body and, via
logger.exception, in the Vector-captured platform log. Trigger is far more often
an ordinary paste than an attacker.

* `models._validate_pat_secret` strips whitespace and rejects anything outside
  printable ASCII, on BOTH BindAgentRepoRequest and ForkToOwnRequest (ent#93's
  create path feeds the same GitHubService constructor).
* That alone would only RELOCATE the leak: Pydantic v2 records the rejected
  value in errors()["input"] and FastAPI returns exc.errors() verbatim — proven
  against a real TestClient. `error_handlers.validation_error_without_input`
  strips `input` from every 422 entry. Dropped for all fields, not for names
  that look sensitive: a name allowlist is the new-producer-missing-from-the-
  consumer's-list class, and the caller already has the value they sent.
* The router catch-all and the PAT-persist log line now scrub, and the
  dual-scrub itself collapses from two copies into one home in
  `utils/credential_sanitizer` (fork_to_own re-exports for its callers).

── Also ─────────────────────────────────────────────────────────────────────

* The bind is `recreate_container_with_updated_config`'s SECOND production call
  site and skipped the `clear_agent_breakers` that `start_agent_internal` runs
  immediately before its own call — both breakers are agent-name-keyed with no
  TTL, so the replacement container inherited its predecessor's verdict
  (#1560). Cleared before the recreate, not after. Two stale "one production
  caller" claims corrected.
* Audit rows on the two idempotency-replay exits, so "exactly once per exit
  path" (#905) is literally true.
* Client timeout resolves against the status endpoint instead of telling the
  user to reload the tab.
* Five test modules registered in tests/registry.json.

Each of the six behaviour fixes was mutation-checked (revert -> red -> restore),
including the breaker clear in both directions (absent, and after the recreate).
Verified: 6332 backtest unit tests pass; the original C1 probe — written before
the fix and unchanged — now reports both post-commit shapes converging; frontend
`vite build` and the design-token check pass; GitPanel's raw-color counts are
unchanged from baseline and BindRepoPanel is at raw_nongray 0.

Refs Abilityai/trinity-enterprise#109

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(git): assert the bind routes are wired to the enumeration-safe deps (ent#109)

Plan §7 lists "uniform 404 for unknown *and* inaccessible agent (Invariant #8)"
as a PR 2 case, and it was the one bullet with no test behind it.

The 404 BEHAVIOUR is not re-tested here — `test_186_enumeration_uniformity.py`
already proves parametrically that both helpers evaluate existence and access
before branching, so nonexistent and inaccessible come back byte-identical.
Re-asserting that would only re-test the shared dependency.

What no dependency-level test can see is whether *this* endpoint routes through
it. So the assertion is the identity of the callable actually bound to
`agent_name` on each route — `get_owned_agent_by_name` on the mutating verb,
`get_authorized_agent_by_name` on the read-only status verb — mirroring the
existing `reject_agent_principal(current_user)` getsource guard: an annotation
that merely looks right in a diff, or a hand-rolled lookup with a 404-then-403
split, is how the enumeration oracle gets reintroduced.

The two scopes are not interchangeable, so both are pinned: swapping them would
either lock a shared reader out of a surface the Git tab already shows them, or
let one rebind an agent they do not own.

Not vacuous: the two dependencies are distinct objects, so binding the wrong one
fails the assertion. Route introspection goes through `route.dependant`, not
`get_flat_dependant` — that symbol drifts in the verify venv.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: trinity-ability <309458136+trinity-ability@users.noreply.github.com>
vybe pushed a commit that referenced this pull request Aug 3, 2026
…tup:` + two HARD-gate fixes (ent#128 PR-B) (#1899)

* fix(agents): stop a malformed `credentials:` also costing runtime + shared_folders

`_resolve_local_template` read `creds.get("mcp_servers", {}).keys()` straight
through the block. A null / list / string `credentials:` raises AttributeError
there, and that read sits FIRST in a run of `config` mutations wrapped in one
broad `except Exception` — so the failure skipped every mutation after it. A
single malformed key therefore silently cost the agent its `runtime:` (wrong
harness) and its `shared_folders:` config too, with only a WARNING to show for
it.

Reads through PR-A's tolerant `credential_mcp_server_names()` instead, so the
credential parse degrades on its own and the unrelated settings survive.

The five malformed shapes are pinned as parametrized regressions; all five fail
on the pre-fix code.

Refs Abilityai/trinity-enterprise#128

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(agent-server): tolerant `credentials:` read on GET /api/template/info

Same uncaught reach-through PR-A fixed on the backend, still live on the agent
image: `.get("credentials", {}).get("mcp_servers", {}).keys()` raises
AttributeError on a null / list / string block at EITHER level, and the
endpoint's own `try/except` wraps only the YAML load — so the crash escaped as a
500 on the Info tab and the brain-orb route guard. `template.yaml` here is read
from the agent's own workspace, which the agent itself can rewrite, so this is
reachable without an operator touching anything.

The agent server ships in its own image and structurally cannot import
`src/backend`, so the reader is DUPLICATED, not imported. The two in-repo
precedents for that (`credential_paths.py`, `model_context.py`) are vendored
byte-identically WITH a parity test; a 6-line reader does not earn a whole
vendored module, but it does earn the same guard — before this commit NO parity
test covered `agent_server/routers/info.py`, so the copies could diverge freely.
Added in the `test_1713_scheduler_utils_parity.py` shape: one shared 17-row table
of malformed shapes driven through BOTH implementations, asserting agreement on
OUTPUT (the copies are textually divergent by design, so a source diff cannot
verify them).

Also routes the endpoint through the existing `get_template_path()` helper —
`/api/metrics` already does — instead of a second copy of the path literal, so
the regression is testable without patching `Path`.

This is the change that makes `/verify-local` mandatory WITHOUT `--skip-agent`.

Refs Abilityai/trinity-enterprise#128

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(compatibility): a credential detector must not read narrower than it audits

K-001 (HARD) compared `.mcp.json.template`'s `${VAR}` references against an
UPPERCASE-ONLY view of `.env.example`. Trinity's substitution engines impose no
charset at all — the agent-side writer is a `str.replace` and the `.env` writer
slices `env_val[2:-1]` — so `${my_var}` IS substituted at runtime, and a template
that correctly documents `my_var=` was HARD-failed for a gap that does not exist.

`services/credential_charset.py` is the one place that decision now lives, named
for its ROLE (`CREDENTIAL_DETECTOR_CHARSET` — "the widest charset a detector must
accept so it is never narrower than the engine it audits"), not for a reach it
does not have. Four detectors adopt it; the docstring carries an explicit
NON-MEMBERS list with a reason per entry, because the previous framing ("the
charset every Trinity surface agrees on") is false and reads as an instruction to
the next engineer who greps `[A-Z][A-Z0-9_]*`:

  * `mcp_validator._ENV_VAR_REF_RE` is a FAIL-CLOSED gate (`.mcp.json` inject →
    400, `.credentials.enc` import, deploy-local), deliberately paired with the
    WIDEST finder (`[^}]*`). Widening it admits input that is currently rejected.
  * `skill_packaging.ENV_KEY_RE` is an adjacent domain with its own length cap.
  * `static_checks._ASSIGN_RE` carries the quantifier shape behind an
    already-FIXED py/polynomial-redos alert, on an agent-supplied-text path.
  * `c_d006` is a different vocabulary that merely looks similar.

The constant lives in a pure-stdlib leaf module, NOT in `services/compatibility/`:
that package's `__init__` imports `database`, and `static_checks` imports
`template_service`, so a `template_service` → compatibility edge is a hard cycle
(reproduced: "cannot import name '_is_platform_injected' from partially
initialized module").

Behaviour changes, both named:
  * K-001 (HARD) `fail → pass` for a documented lowercase variable — the fix.
  * K-003 (SOFT) `pass → fail` for a lowercase-only, comment-free `.env.example`.
    `_env_example_vars` is K-003's precondition for DEMANDING comments, so growing
    it makes the verdict worse. The verdict is correct — that file genuinely has
    no comments — but it is a `pass → fail` and is release-noted, not smuggled.
  * S-010 (SOFT) does NOT flip: its `generic` blocklist is uppercase-exact, so no
    newly-visible lowercase name can join it. Asserted, not assumed — it is safe
    by coincidence of casing.

The two `template_service` extractors are included because both feed the LIVE
`collect_mcp_credential_warnings` → `deploy.py` path; leaving them out would
half-fix the very inconsistency this closes while a four-way agreement test
passed. Direction there is FEWER spurious warnings. `test_deploy_local_validation.py`
(the 8-assertion suite on that path) stays green.

Also hardens two latent crashes in the same file: a null / non-mapping
`template.yaml` document reaching `extract_credentials_from_template_yaml`, and
`extract_agent_credentials` reaching through `credentials:` at three levels. New
`credential_mcp_env_vars()` reader returns non-empty strings only, so an
`env_vars` element smuggled in as a mapping can never reach a consumer — that
element is exactly what turns a set comprehension into
`TypeError: unhashable type: 'dict'`.

Refs Abilityai/trinity-enterprise#128

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(compatibility): K-002 compared ${VAR}s against section names, and could go dark

Two defects in the same HARD gate, one of them a way for the gate to stop
protecting entirely.

**1. It read the structure, not the declaration.** `listed` was
`set(creds.keys())` — `{"mcp_servers", "env_file"}` — so the documented structured
form `credentials.mcp_servers.stripe.env_vars: [STRIPE_API_KEY]` satisfied nothing
and HARD-failed a correctly declared template, while `${env_file}` and
`${mcp_servers}` PASSED. The admitted set was "whichever section names this
template happens to use", so the blind spot was template-dependent — the worst
kind, because it cannot be found by reading the check.

`declared_credential_names()` (the union of `mcp_servers.*.env_vars` and
`env_file`, over PR-A's tolerant readers) is now unioned in, and the three known
STRUCTURE keys are subtracted. A flat `credentials: {STRIPE_API_KEY: '...'}`
mapping is still admitted — that legacy shape is legitimate and keeps passing.

The section subtraction is a deliberate `pass → fail` for a genuinely broken
template. Shipped named, tested and release-noted, NOT smuggled under a
monotonicity claim: the blanket "strictly monotone, fail→pass only" claim is false
and a reviewer would find the counter-examples.

**2. It could go dark.** `run_static` caught `Exception` → `skipped`, and `_counts`
counted only `status == "fail"`, so a raise inside a HARD check DROPPED
`hard_count` and could flip `overall_status` from `issues` to `compatible` on an
agent with a genuinely undeclared credential. `c_k002` delegates to `c_t015`, so
ONE raise took both HARD gates dark together, and the result is indistinguishable
from a clean pass in the counts. The trigger is four lines of untrusted YAML:

    credentials:
      mcp_servers:
        s:
          env_vars:
            - {STRIPE_SECRET_KEY: "please"}

`template.yaml` here is read from a live agent workspace, whose git repo the agent
itself owns — a self-attestation bypass on the surface whose job is to police it.
The same `TypeError: unhashable type: 'dict'` is the failure mode that argued
against enriching `credentials.env_file` in the first place, so reintroducing it
at the new call site would have been the plan diagnosing a bug and then shipping
it.

Three layers, deliberately:
  * `c_t015` wraps ONLY the new term and degrades to the narrower set — which
    makes `missing` LARGER, i.e. errs toward failing — never to `skipped`.
  * `run_static` returns FAIL for a check that raises. A check that could not
    evaluate is not a check that passed; one bad check still never breaks the
    report.
  * `_counts` also counts `skipped` + `skip_reason == "check_error"` as a finding,
    at the sink (#1525), so the property survives a future path reintroducing the
    skip. A benign precondition skip (`no_template`, `ai_not_run`) still counts as
    nothing — that distinction is why the skip path exists.

`declared_credential_names` guarantees `str` elements structurally, and the call
site filters `isinstance(name, str)` anyway: the gate must not depend on the
reader's contract holding.

Refs Abilityai/trinity-enterprise#128

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(templates): MCP-server precedence, and a credentials badge that counts

Three catalog defects PR-A deferred, all in the two builders.

**Defect D — precedence was backwards.** `_build_local_template` read
`credential_mcp_server_names(credentials_block) or data.get("mcp_servers", [])`, so
a `credentials:` block silently OUTRANKED the template's own `mcp_servers:`
declaration. `agent_server/routers/info.py` has always read them in the other
order, so the catalog and the agent's own Info tab disagreed for any template
declaring both. Operands flipped; the `credentials:` path stays as the fallback.

**W14 — the GitHub builder had no fallback at all**, so a GitHub template declaring
only `credentials.mcp_servers` showed an empty list in the catalog while its Info
tab listed them. That was the third of three surfaces; all three now agree.

**Defect C / W6 — the badge.** Both builders read a flat top-level
`required_credentials:` key that ZERO templates declare — 25 bundled and all 7
configured GitHub repos — so `Templates.vue` rendered 0 for everything. Now derived
from the declared base set, with `platform_injected` vars EXCLUDED.

That exclusion is the badge's semantic, and it is not cosmetic: measured on the
real shipped catalog, a naive derivation is correct on 1 of 7 repos and wrong in
both directions — the ent#124 first-run agent would read 5 where the operator
supplies 2, while three shipped repos stay at 0. The chip is read as "how much work
is this to set up", so counting `GEMINI_API_KEY` / `GITHUB_PAT` / `TRINITY_*`
inflates it with rows nobody can fill. A consumer that wants every declared
variable wants `declared_credential_names`, not this.

Derived unconditionally rather than "explicit key wins, else derive": that override
branch is unreachable (no template declares the key), so keeping it would be one
dead code path guarding a live one.

No frontend change: `Templates.vue:103,107,171,175` read only `.length`, so the
shape it already expects is preserved and a variable name never reaches the DOM.

Refs Abilityai/trinity-enterprise#128

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(templates): `credential_setup:` — per-variable credential setup metadata

Closes ent#128 AC #1-2. A template can now describe each credential an operator
must supply — title, description, required, secret, format, setup_url, default —
and `template_service` surfaces the normalized result as `credential_requirements`
on every catalog entry.

**Enrichment lives in a NEW sibling top-level key; `credentials:` is FROZEN as
names-only, forever.** An already-deployed older Trinity reads `env_file` through
`credential_env_file_names` and then does `agent_credentials.get(var_name, "")` —
hand it a list of mappings and that is `TypeError: unhashable type: 'dict'` at the
moment it writes the agent's `.env`. A sibling key is structurally invisible to
that binary, so there is no floor version and enrichment distributes immediately.

**Base-set-plus-overlay, so the two keys cannot drift.** One record per variable
`credentials:` declares, decorated by `credential_setup:` entries joined BY NAME.
An entry naming nothing is a named three-line error (problem, cause, FIX) and is
dropped; valid siblings survive. `credential_setup:` can only ever decorate — the
sibling-key shape's usual failure mode is closed by construction, not by
discipline. Stated honestly: for an EXTERNAL template that error is neither
impossible nor visible in the UI — `credential_errors` has zero frontend and zero
MCP consumers, so the only human channel is the backend log. It is LOGGED.

`required` is a tri-state. Enriched-and-omitted means `True` (an author who
described a variable meant it); a legacy bare `- FOO` is `"unknown"`, never `True`
— it carries no authorial intent, and reading it as required makes a guided
checklist cry wolf. `"unknown"` doubles as the enriched/un-enriched discriminator,
which is why no `enriched: false` flag is needed. `secret` defaults `True`
(fail-safe). Path-free by construction, so trinity#570's `template.yaml` →
`trinity.yaml` rename cannot reach it.

**The normalizer never raises, and that is load-bearing.** `_build_template` runs
in bare list comprehensions in `get_all_templates()`, OUTSIDE PR-A's per-template
fence (which covers `_build_local_template` only) — a raise there is HTTP 500 with
an EMPTY CATALOG, i.e. PR-A's exact bug reopened by the change that surfaces the
new metadata. And no bomb is needed: `title: 123` or a bare `title:` was enough.
So the builders ALSO wrap the call and degrade to `[]` plus a named error, rather
than fencing the comprehension — that keeps the named error the resilience
contract promises. The property does not rest on one function's discipline.
(Which earned its keep immediately: the wrapper caught a real NameError during
development instead of emptying the catalog.)

Trust boundary — `title`/`description`/`setup_url`/`name`/`source` are
author-controlled strings from arbitrary GitHub repos flowing into an
operator-facing "paste your API key" checklist:

  * **Type-guard before touching.** Never `str()` a container from untrusted YAML:
    `str()` EXPANDS a shared alias during the walk (443 B → 52 MB in 1.5 s, x10 per
    level), and both the sanitizer and the record cap act after that cost is paid.
  * **Cap the INPUT**, entries AND errors AND the base set. Capping records while
    leaving `errors` uncapped built a 35 MB response out of the cap meant to
    prevent it; and `default` had no type row, so the 100-record cap acted as a
    x100 multiplier on it.
  * **`source` is sanitized** — it carries the raw MCP server name, the exact
    string `_sanitize_for_warning`'s own docstring names as the threat, and it was
    not on the list.
  * **Per-field length caps.** Reusing the 80-char terminal-warning default
    truncated a realistic 159-char description and made a real 90-char vendor
    console URL unusable.
  * **`setup_url` above scheme-only**: https (case-insensitive — `HTTPS://` is a
    legitimate author), a parseable host, NO userinfo (`https://google.com@evil.tld`
    renders as one host and resolves to another — the display/resolve split IS the
    attack), ≤2048, printable. Validate THEN sanitize, and never through a
    truncator. Residual documented, not claimed closed: `isprintable()` rejects
    RTL/ANSI but an IDN homograph survives, so a consumer must render the parsed
    hostname beside the link.
  * **Never mutates its input** — `_metadata_cache` holds the parsed dict for 600 s
    and YAML aliases genuinely share nodes, so one in-place normalize would rewrite
    both aliased fields and persist for ten minutes. Asserted against a deep-copy
    snapshot, including a real `&anchor`/`*alias` document.

`credential_shape_errors` also gains the per-server and per-ELEMENT rows for
`mcp_servers`, mirroring what `env_file` already had. The element row is the one
that matters — an `env_vars` entry smuggled in as a mapping was the single most
dangerous shape in the block and was unnamed. Note this makes the write path
(`generate_credential_files` → 400) reject a template that previously created an
agent with a garbage declaration: correct per PR-A's fail-loud write contract, and
release-noted.

`generate_credential_files` is deliberately UNTOUCHED — it still reads `env_file`
names-only, which is what makes the forward-compatibility argument true rather
than asserted.

Refs Abilityai/trinity-enterprise#128

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(schemas): trinity-agent-credentials.schema.json — the declaration contract

Closes ent#128 AC #3's machine-readable half. Follows the established
`docs/schemas/` convention (`agent-pipeline.schema.json`): Draft 2020-12,
date-stamped `$id` so a future revision keeps answering for templates written
against this one, and self-described as the authoritative documentation contract
while the backend reader stays deliberately tolerant.

**Rooted at `template.yaml`, not at `credentials:`.** The two keys are ONE contract
joined by a mandatory cross-reference, and validating either alone cannot check it.

**`additionalProperties: true` at the root and on `credentials`** — template.yaml
carries many keys this schema deliberately says nothing about, and a template
predating the schema must stay VALID. Accepted asymmetry, and it is asserted as a
test rather than left as a surprise: a made-up top-level key IS valid here.

**`config_files` is enumerated and `deprecated: true`, not omitted.** The earlier
posture was "don't delete, don't advertise", which made the authoritative contract
answer VALID to `path: "/etc/cron.d/pwn"`. Undocumented is not a control against an
author who knows the key — only against the reviewer who doesn't. So it is
documented as deprecated, with a containment `pattern` that rejects absolute and
`..` paths and a description saying plainly that it writes files into the agent's
credential directory. Still reversible, still invalidates nobody. (Whether to
DELETE the key is a public behaviour change and stays @vybe's call.)

Carries the A2 consumer requirements in `$comment`, because the schema is the
artifact a downstream implementer reads:
  * a record with `required: "unknown"` carries no authorial intent and MUST NOT be
    presented as a required field — without this a naive UI renders a seeded agent
    as five mandatory rows, three of them platform variables nobody can fill;
  * `platform_injected: true` MUST NOT be asked of an operator;
  * `secret: true` (the default) MUST be masked;
  * `setup_url` MUST be rendered with its parsed hostname shown, because the IDN
    homograph residual is real and documented rather than claimed closed;
  * there is intentionally NO reverse cross-reference requirement — a declared
    variable with no `credential_setup:` entry is normal.

Also states the author cost honestly in the authoring note: declaring in
`credentials:` is a separate edit from referencing `${VAR}` in
`.mcp.json.template`, K-002 checks the two agree, and that is deliberate because
`.mcp.json.template` must not become a second declaration authority. Plus the two
brace forms Trinity's readers cannot see (`${my-key}`, `${VAR:-default}`).

Tests pin the schema against the implementation — field caps, the format
vocabulary, the allowed-key set, the record cap — so the reviewed text and the
enforced text cannot drift. The 13 document cases run under `importorskip`
(`jsonschema` is not a declared Trinity dependency); the security-relevant pattern
assertions are unconditional.

Refs Abilityai/trinity-enterprise#128

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(templates): the Trinity-installable credential contract + reference examples

Closes ent#128 AC #3-4.

**Reference examples (AC #4).** The substrate the original plan targeted is gone —
`3317247e` deleted `config/agent-templates/cornelius/` in favour of seeding from
the public upstream repo — so AC #4 lands on what the bundle actually has:

  * `scout` / `sage` / `scribe` (the ent#124 seeded trio) declare an explicit
    `credentials: {}` with the zero-credential contract written out. Absent and
    empty mean the same thing to Trinity, but *absent* is ambiguous to a HUMAN —
    it could equally mean the author forgot. `{}` says "considered, and there are
    none", so the catalog's 0-credential badge is trustworthy.
  * `test-codex` carries the enriched reference: its one real variable gets a
    title, description, `required`, `secret`, `format` and `setup_url`.

Deliberately NO `GEMINI_API_KEY` in any example: it is platform-injected, so an
example asking for it would violate the very rule the guide documents — and it
makes a K-002 fixture pass VACUOUSLY, which is how a test proves nothing while
looking green. A test asserts no bundled example asks for a platform-injected var.

Framed honestly rather than oversold: with one enriched declaration and one
names-only one in the bundle, the parity test ("every bundled template normalizes
with zero errors") is thin today. Its value is as a RATCHET for ent#137's curated
fleet.

**The guide (AC #3).** New `## Declaring Credentials` section, TOC renumbered
5→21. Covers the field table, the decorate-don't-declare rule with the actual
error text, why `credentials:` stays names-only, the zero-credential contract,
degrade-don't-demand, the platform-injected list, fork-to-own composition
(ent#109), and the two brace forms Trinity's readers cannot see (`${my-key}`
silently dropped, `${VAR:-default}` mis-substituted to an empty string).

It also states the AUTHOR COST plainly instead of claiming the design is free:
declaring a variable is a separate edit from referencing it in
`.mcp.json.template`, and three of Trinity's own six default GitHub templates
declare zero credentials while referencing 2-6 and documenting 7-12. Those are
K-002-red today and stay red until someone does the edit. Kept that way on purpose
— if `.mcp.json.template` counted as a declaration it would become a second
authority on what an agent needs, which is the drift this design exists to
prevent. The practical order is stated: seed `credentials:` first, enrich second.

**Memory docs.** `requirements/credentials.md` §3.5's ✅ was false in both halves
and is corrected in place with the correction recorded: the extractor it credited
has no production caller, and nothing showed configured-vs-missing status because
the badge read a key no template defines. `template-processing.md` and
`templates-page.md` get the "two shapes, two owners" table that reconciles the
objects-vs-strings contradiction (catalog `required_credentials` = names,
`credential_requirements` = objects, extractor `required_credentials` = a
different function with the same key name), plus the corrected regex. Compatibility
checklist gains six credential rows and a starts-with-nothing-configured row.

**No DB change → Rule #9 (dual-track migration) does not apply.**

Refs Abilityai/trinity-enterprise#128

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(templates): close the ent#128 coverage gaps the gate surfaced

A transition diff over a corpus with zero coverage of the diff is not evidence, so
the changed statements were measured against PR-B's real base (`c07afab7` =
origin/dev + PR-A) rather than assumed. The gate found the new paths that no test
reached and this closes them:

  * §4's new `mcp_servers` shape-error rows — per-server AND per-element, six
    parametrized cases plus the sanitized-server-name case. The element row is the
    dangerous one and it had no test.
  * The write-path consequence, asserted explicitly: `generate_credential_files`
    now raises on `env_vars: [{K: v}]`, where before it created the agent silently.
  * `_setup_url_error`'s `urlsplit` ValueError branch (malformed IPv6 literal).
  * The dedup early-return in the base-record builder — a variable declared under
    two servers AND `env_file` yields one record with a stable `source`.
  * A non-string mapping key in a descriptor (`{1: "x"}`), which must not reach the
    "did you mean" helper.
  * The caller-less `extract_agent_credentials` across eight malformed shapes. It
    has no production caller, which makes hardening cheap rather than unnecessary —
    the next caller would have inherited the crashes. Now exercised instead of
    merely present.

Result: 227 changed statements, 225 executed. The two remaining are a defensive
`except OSError` around a `Path.resolve()`, and the three gate files
(`static_checks.py`, `compatibility/__init__.py`, `credential_charset.py`) are at
100% of changed statements.

Refs Abilityai/trinity-enterprise#128

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(feature-flows): sync the compatibility flow + index for ent#128

`/sync-feature-flows`. `template-processing.md` and `templates-page.md` were already
updated with the declaration standard; this adds the flow the code change actually
lands hardest on and which nothing had touched: `agent-compatibility-validation.md`.

Both credential HARD gates changed, and the flow doc described neither the defect
nor the new semantics:

  * "a detector must never read narrower than the mechanism it audits" — the shared
    root cause of K-001 and K-002/T-015, with the NON-MEMBERS list spelled out so
    the next reader does not "align all the regexes" and widen
    `mcp_validator._ENV_VAR_REF_RE`, which is a fail-closed GATE and not a detector;
  * "a HARD gate must not be able to go dark" — the `run_static` →`skipped` +
    `_counts`-counts-only-`fail` interaction that let 4 lines of untrusted YAML drop
    `hard_count` 1→0, and the three fail-closed layers that replace it;
  * the complete verdict-transition set, because the blanket "strictly monotone"
    claim is false and a reader will find K-003's `pass→fail`. The claim that
    survives is "no agent gains a HARD failure".

Testing section records why the bundled templates cannot prove any of this — 0
`.mcp.json.template` and 0 `.env.example` files, so every changed check
short-circuits before reaching changed code and a green diff there is
green-because-vacuous — and points at the 49-fixture synthetic corpus instead.

Plus the Recent Updates row in `feature-flows.md` (the step this skill's own docs
warn gets skipped).

Observation, deliberately NOT fixed here: the Recent Updates table carries 57 rows
against its own documented "newest ~20" cap (#1360), so the index is 434 lines vs
the 400-line guideline. Trimming it means deleting 37 other engineers' entries,
which is not this PR's call.

Refs Abilityai/trinity-enterprise#128

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(templates): use an unambiguous placeholder credential value

`sk-live-xxx` is stripe-shaped and gitleaks' default ruleset covers `sk-`. The value
is arbitrary in this test — it only has to round-trip byte-identically through the
`.env` writer — so there is no reason to hand CI a secret-shaped string to reason
about.

Refs Abilityai/trinity-enterprise#128

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(agents): teach the crud harnesses the tolerant credentials accessor

Real regression I introduced in `886aab5b` and initially mis-attributed as
pre-existing. Recording both the fix and how the mis-attribution happened, because
the second part is the reusable lesson.

**The bug.** `test_1484_create_agent_characterization.py` and
`test_1759_local_template_not_found.py` MagicMock the whole
`services.template_service` module and stub each function crud actually calls with a
faithful return value (`generate_credential_files` → `{}`, `get_github_template` →
`None`). `_resolve_local_template` now calls a THIRD one —
`credential_mcp_server_names` — and it was unstubbed, so it returned a truthy Mock
that passed `if mcp_servers:`, landed in `config.mcp_servers`, and blew up later
inside a `yaml.dump` as `ValueError: dictionary update sequence element #0 has
length 1; 2 is required`. 18 tests, entirely a harness gap: in production the real
function returns a list.

Stubbed with a faithful 3-line mirror rather than a fixed `[]`, so a fixture that
DOES declare `credentials:` cannot be silently masked by the stub.

**One test needed a real update, not a stub.**
`test_malformed_field_still_creates_and_names_the_template` used
`credentials: "a string"` as its trigger for the broad-except degrade path. That is
exactly what `886aab5b` fixes — `credentials:` is no longer a trigger BY DESIGN,
because it raised FIRST in that run of mutations and so cost the agent its
`runtime:` and `shared_folders:` config as collateral. Swapped the trigger to
`shared_folders: not-a-mapping`, which still raises, so the degrade path and the
two identifiers in its warning stay under test. The docstring records why and points
at the new coverage.

**How I mis-attributed it.** I compared with `git stash push -- src/backend`, which
reverts only the WORKING TREE — commits 1 and 2 were already committed, so my
"baseline" still contained the cause and the failures looked identical on both
sides. The `-k`-filtered selection also happened to include only 1 of the 13
`test_1484` failures, which made the set look small and stable. Only a worktree at
`c07afab7` (PR-A's tip, PR-B absent) showed the truth: 2 failures there vs 20 on the
branch. **A baseline has to be a worktree at the base commit, not a stash.**

Now identical to `origin/dev` and to `c07afab7`: 2 failures, both genuinely
pre-existing (`test_agent_analytics::test_day_stacks_present_in_by_type`,
`test_1069_voip_call_path_param` — the documented `get_flat_dependant` venv drift).

Refs Abilityai/trinity-enterprise#128

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(templates): the tolerant credential reader must not blow up or raise

Two holes in the "never raises, never amplifies" property PR-B rests on, both
found by asking which OTHER producers reach the surface the new cap protects.

1. `credential_shape_errors` was uncapped. The cap shipped on the NEW function
   (`normalize_credential_requirements`), but the same PR added a per-ELEMENT
   loop to this PRE-EXISTING one, and it feeds the same two surfaces: the
   catalog's `credential_errors`, and the `"; ".join(errors)` that becomes
   `CredentialDeclarationError`'s agent-creation 400 body. A cap is a property
   of the producer, not of the PR that invented the concept.

   YAML anchors make input size a useless proxy for output size, so the bound
   has to stop the WALK, not slice the result. Measured on a 6,738-byte
   `template.yaml` (one 200-element anchor aliased across 200 servers):
   40,000 errors / 3.64 MB joined (540x) before, 101 errors / 8,973 bytes after.
   `origin/dev` returns 0 on the same input, so the amplification is this
   branch's own — reachable since ent#123 by any creator-role user pointing at
   an arbitrary public repo.

2. `source_trust not in _SOURCE_TRUST_LEVELS` is frozenset membership, so an
   UNHASHABLE value raised `TypeError` *on the guard line* — before the
   degrade-to-`github` branch that guard exists to reach. Unreachable from
   parsed YAML today (every call site passes a literal), but this is the one
   function whose docstring makes "NEVER RAISES" load-bearing: a raise here is
   an empty catalog and a dark HARD gate. The property should be literally
   true, not true-by-call-site-audit.

Both regression tests were confirmed to FAIL with their fix reverted and pass
with it restored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* build(tests): cap fastapi to prod's 0.115.x line

The unit suite was validating against a FastAPI ~25 minor versions ahead of
the one production ships. `docker/backend/Dockerfile` pins `fastapi==0.115.6`
exactly; `tests/requirements-test.txt` carried an unbounded floor that resolved
0.140.13. The comment at :43 already claimed these "match the floors set in
docker/backend/Dockerfile" — that file uses exact pins, so the claim was untrue.

Surfaced as `test_1069_voip_call_path_param` failing with `ImportError: cannot
import name 'get_flat_dependant'`. That test is only the messenger: it is the
one test coupled to a private FastAPI symbol (`src/backend` imports none, and
the other test touching `fastapi.routing` uses the public `APIRoute`).

The obvious ceiling does not work: `0.140.13 < 0.141` is true, so `<0.141`
still admits the breaking version. Bisected against the real wheels — present
in 0.140.6, gone in 0.140.7 — a private API dropped in a PATCH release, so no
minor-level bound is trustworthy. Tracking prod's line is the durable fix.

Why now rather than "separate follow-up": CI is green only on a warm pip cache.
backend-unit-test.yml keys `cache-dependency-path` on this file, and 0.140.13
allows py3.11, so the next edit to this file for ANY reason busts the key,
re-resolves, and breaks CI for everyone. Capping is the safe way to bust that
cache — the change that invalidates the key is the one that makes re-resolution
correct.

Follows this file's own precedent (`bcrypt>=4.2.0,<5`, added when bcrypt 5.0.0
removed the `__about__` shim passlib reads): floor + ceiling + a comment saying
why, rather than an exact pin that would break the file's `>=` convention.

Verified by execution, not argument:
  - full tests/unit at 0.115.14: 5861 passed, 16 skipped, 2 xfailed, 0 failed
    (at 0.140.13 the same command is 1 failed, 5860 passed)
  - the edited file installs clean in a fresh venv and resolves 0.115.14
  - an existing verify venv self-heals: pip downgrades 0.140.13 -> 0.115.14

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(templates): take the trust label from the caller, not a tainted path

`_build_local_template` derived `is_bundled` itself:

    is_bundled = template_dir.resolve().parent == _local_templates_dir().resolve()

`template_dir` on the by-id path is `_local_templates_dir() / name` where `name`
comes from a user-supplied `local:<name>` template id, so this called `.resolve()`
on attacker-influenced input. CodeQL flagged it as `py/path-injection` (alert 260,
high) — a new tainted-path sink introduced by ent#128 purely to pick a log level
(`source_trust` selects `logger.warning` vs `logger.info` and nothing else).

`is_bundled` is now a required keyword arg supplied by whoever knows the
provenance:

  - `get_local_templates()` iterates the curated root, so its children are
    bundled by construction -> `is_bundled=True`.
  - `get_local_template()` decides from the id STRING (plain single segment, no
    separator, not a dot-segment) rather than a path operation on it.

Behaviour, measured against the old predicate across 9 ids: 7 identical, 2
divergent — `'../agent-templates/sage'` and `'a\b'` go True -> False. Both moves
are old=True -> new=False, i.e. strictly more conservative: the new check never
grants the `bundled` label where the old one withheld it, only the reverse. An id
that traverses to arrive inside the curated root is not curated, so the new
answer is also the more correct one; the blast radius either way is one log
level.

This is deliberately NOT a traversal guard — and as of the 2026-08-02 rebase it
no longer needs to be. An earlier version of this message said the traversal was
"being routed as its own issue rather than fixed"; that issue, #1900, has since
been fixed on `dev` by #1935, which this branch is now rebased onto.
`get_local_template` therefore routes `name` through `contained_template_dir()`
— a name allowlist plus resolve + `is_relative_to` — BEFORE the label check runs.
(The traversal was real while it lasted: `local:..` escaped the templates dir,
reachable by any authenticated user via `GET /api/templates/{id:path}`.)

That makes the `is_plain_segment` check redundant today — provably True wherever
it is reached, since the barrier above rejects every non-plain name first. It is
kept as defence in depth: it decides a trust LABEL, and `contained_template_dir`
is a shared primitive the remote-template-registry work (trinity-enterprise#14)
is expected to edit. A label that silently became `bundled` if that barrier were
ever widened is the exact failure this keyword argument exists to prevent. It
re-adds no tainted-path sink — it reads the id string, never the filesystem.

Two test call sites updated for the new signature.

Verified on the rebased branch: full tests/unit 6410 passed / 16 skipped /
0 failed; no `template_dir.resolve()` remains in the module.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
vybe pushed a commit that referenced this pull request Aug 3, 2026
)

Trinity Rule #1 — the requirement lands before the code. Documents the
three-part shape (container config-truth probe, start-time drift predicate,
deliberate rotation), the ordering/concurrency contract (fail-closed lock,
captured-id deletion, DELETE-not-deactivate, DB-only path for stopped agents),
the allowlist auth rule, the `stale` health state, and what stays exploitable
after the change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
vybe pushed a commit that referenced this pull request Aug 3, 2026
…t creation (trinity-enterprise#89) (#1946)

* docs(requirements): template-declared schedules at creation (§10.16, ent#89)

Requirements-first per Rule of Engagement #1 — written and committed before
any implementation.

Covers the declared `schedules:` contract, the total-function reader and its
tolerance matrix, the normalized carrier that feeds BOTH resolver branches
(the `github:` half needs the creation-resolved PAT + parsed ref, not the
catalog's global-PAT cached fetch), the honored-`enabled` decision and its
`set_autonomy_status` caveat, idempotency in all three places creation /
intra-block / manifest deploy, and the T-018 + A-002 + c_p006 compatibility
half.

Refs trinity-enterprise#89

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(templates): tolerant `schedules:` reader for template.yaml (ent#89)

New leaf `services/template_schedules.py`: `schedule_shape_errors` +
`normalize_declared_schedules` over one private `_parse`, mirroring the
sibling `credential_shape_errors` / `credential_mcp_server_names` convention
(ent#128).

The contract is TOTALITY — template.yaml is untrusted and `yaml.safe_load()`
can yield a scalar, list or mapping at any level, so a raise here would empty
the template catalog (#1835 class), enter the creation rollback fence, or
fail-open the T-018 check. Every shape degrades to a safe value plus a named
error.

Cron and timezone are validated with the SAME parser the dedicated scheduler
registers with (#1472) because `_calculate_next_run_at` swallows a bad cron
and `set_schedule_enabled` never re-validates — an unvalidated entry would
become a zombie schedule that exists, shows no next run, and can never fire.

Errors name the index, the key and a YAML type — never the `name`/`message`
VALUE, which is unbounded and lands in a persisted, UI-rendered blob. The
cron/timezone strings are the one echo, bounded and printable-filtered by a
local twin of `_sanitize_for_warning` (importing it would close a cycle).

Refs trinity-enterprise#89

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(templates): surface `schedules:` in both builders, fence both GitHub list paths (ent#89)

Three changes in one file:

1. `_build_template` (GitHub) and `_build_local_template` (local) both surface
   `schedules` (normalized) + `schedule_errors`. BOTH, explicitly — the
   pre-existing asymmetry (`persistent_state` is surfaced only by the GitHub
   builder) means parity cannot be assumed, and AC #2 covers both sources.

2. R3 — both GitHub catalog list paths were BARE list comprehensions.
   ent#128 PR-A fenced `_build_local_template` only, so adding an
   untrusted-input reader to `_build_template` would have put a new
   raise-capable call on an unfenced path: one malformed repo would 500 the
   whole GitHub half of GET /api/templates. That is the #1835 bug this
   feature is modelled on, re-opened by the feature itself. Both are now
   fenced per-template via `_safe_build_github_template`.

3. `fetch_template_metadata_for_create` — the creation path must NOT read the
   catalog cache. That cache uses the GLOBAL platform PAT (creation resolves
   per-agent -> per-user -> global, ent#162), sends no `?ref=` (so an
   `@branch` create would read the default branch), and is a 10-minute
   per-process TTL. The new fetch takes the resolved PAT + parsed ref, and is
   loud on every failure — a silently empty declaration on the `github:` path
   is the exact class this feature exists to close.

Refs trinity-enterprise#89

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(agents): materialize a template's declared schedules at creation (ent#89)

`_TemplateResolution.declared_schedules` is a normalized carrier populated by
BOTH resolver branches, so AC #2's "GitHub and local" is real rather than
nominal. It is deliberately not folded into `template_data`: that field is raw
template YAML on the `local:` path and `{}` on the `github:` path — which has
never populated it, so #383's `persistent_state` and #1169's `data_paths` are
effectively `local:`-only — and `_stage_config_files` gates credential-file
generation on `if template_data:`, so merging the two shapes would change
credential generation for every GitHub agent.

`reconcile_declared_schedules` is shaped as a reconcile primitive (takes
`agent_name`, not `AgentConfig`) so a future "re-apply template" can reuse it.
No recreate hook is added — an eager re-materialize would resurrect schedules
an operator deliberately deleted.

Two failure modes handled explicitly:
- `db.create_schedule` RETURNS None on three paths (unknown user, no access,
  the #1445 is_agent_live gate) and never raises, so a try/except alone would
  catch nothing and a length-derived counter would report schedules that were
  never written. The return value is checked and counted as failed.
- The whole step is non-fatal and the try/except wraps the entire call
  including `list_agent_schedules` — this function sits inside the destructive
  rollback fence, so an escaping raise would roll back a successful creation
  over a schedule.

`enabled` is passed explicitly (ScheduleCreate defaults it to True, which
would invert AC #3), and ghosts are skipped at the caller per ent#69 fleet
hygiene.

Refs trinity-enterprise#89

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(systems): don't duplicate a schedule the template already materialized (ent#89)

`deploy_manifest` creates each agent and THEN calls `create_schedules`, so
post-ent#89 it is the second schedule producer for the same agent: the first
call materializes the template's declared block, the second adds the
manifest's. With no UNIQUE(agent_name, name) index, a manifest declaring
`daily-briefing` on a template that also declares it produced two rows.

This is a regression ent#89 itself creates, so the guard ships with it rather
than as a follow-up. Fails open on a read error — dropping a manifest's
schedules would be worse than the duplicate this prevents.

Refs trinity-enterprise#89

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(compat): T-018 schedules well-formedness + three in-radius corrections (ent#89)

T-018 (SOFT, STATIC) reports the `schedules:` block's STRUCTURE only, sharing
one reader with the materializer and the catalog surface so the report cannot
drift from what creation actually does. It deliberately does not report cron
syntax — see correction 2.

It fails CLOSED, against the grain of every other check: `run_static` turns a
raise into `skipped` and the report counts only `fail`, so a raising SOFT
check drops soft_count 1->0 and flips overall_status issues->compatible
exactly when its finding was the only failure — the entire population T-018
exists to serve — and `_report_from_persisted` then replays that clean bill of
health from checks_json on every stopped-agent read. Detail carries the
exception TYPE only; `str(e)` can embed untrusted template content into a
persisted, UI-rendered blob.

Three corrections, all in blast radius:

1. `c_p006` was missing the `isinstance(..., list)` guard its four sibling
   readers of this field all carry. `schedules: 5` raised TypeError -> swallowed
   -> a HARD check silently vanished from hard_count. A live instance of the
   exact class T-018 guards against, which is what makes the fail-closed design
   evidence-backed rather than theoretical.

2. `_valid_cron` (A-002) was a per-field `^[\d*/,\-]+$` regex, wrong in BOTH
   directions: it rejected `0 9 * * MON` and accepted `99 99 * * *`. It now
   delegates to `validate_cron_expression` — the same parser the scheduler
   registers with (#1472) and the same one the ent#89 reader gates on. One
   cron authority, agreeing with the executor.

3. `run_static`'s swallow now logs. It previously left no trace anywhere, for
   all ~100 checks; this is the instrument for deciding later whether to flip
   it to `fail` platform-wide.

Catalog 100 -> 101.

Refs trinity-enterprise#89

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(templates): stop echoing the schedule name in the duplicate error (ent#89)

The error list is persisted into agent_compatibility_results.checks_json,
rendered in the UI, and returned in the catalog response, so the discipline is
index + key + type name only. The entry index already identifies the offender;
the name added disclosure without adding actionability. Cron and timezone stay
the only echoed values — bounded, printable-filtered, and what makes those
particular errors fixable.

Refs trinity-enterprise#89

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test: cover template-declared schedules end to end (ent#89)

Three new files plus compatibility extensions, 143 tests.

test_ent89_template_schedules.py — the reader's tolerance matrix (40 rows),
error-string discipline (no name/message/description ever echoed; cron
sanitized and bounded), the catalog surface in BOTH builders, the GitHub
list-path fence, and the create-path fetch. Plus a Hypothesis property over
recursive JSON-ish values asserting totality — a 40-row matrix cannot be a
totality proof for `yaml.safe_load` output, and totality is this module's
entire contract.

test_ent89_schedule_materialization.py — REAL DB rows via db_harness, not
`mock.assert_called`. Both recorded lessons apply here: a mock-`db` suite is
blind to a facade gap, and a parameter only one branch consumes is a severed
wire a mock will happily confirm — which is precisely the failure history of
the `github:` half of AC #2. So both resolver branches are driven for real,
and the github fetch's PAT and ref are asserted; without that the §0
regression test would be self-attestation.

test_ent89_manifest_no_duplicate.py — R5, in creation order: template first,
manifest second, one row, and the template's row is not overwritten.

test_compatibility_checks.py — T-018 pass/fail/absent, its fail-closed branch
(and that `detail` never carries `str(e)`), a `build_report`-level test
pinning the DIRECTION (a raising reader must still yield `overall_status ==
"issues"`), the `_report_from_persisted` recompute, A-002 in both directions
and agreeing with the materializer, c_p006 on `schedules: 5`, the run_static
log, and the whole static catalog run against 7 hostile templates asserting no
check lands at `check_error` — a T-018-only assertion would never have caught
c_p006.

The two fence tests pin `get_github_templates` by sys.modules KEY rather than
by module object: `get_all_templates` imports it lazily inside the function,
so an attribute patch on a separately-imported reference passed in isolation
and failed under the full suite, where an earlier file swaps that object.

Full unit suite: 6405 passed. The one red, test_1069_voip_call_path_param, is
a pre-existing venv FastAPI drift (`get_flat_dependant` no longer exported) in
a file this branch does not touch.

Refs trinity-enterprise#89

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(architecture): template-declared schedules + the compat fail-open class (ent#89)

- template_service bullet: the `schedules:` reader beside the `credentials:`
  ones, the newly fenced GitHub catalog list paths, and why the creation path
  needs its own metadata fetch rather than the global-PAT default-branch cache.
- New `template_schedules.py` leaf bullet with its totality contract.
- crud bullet: the `declared_schedules` carrier, why it is not `template_data`,
  and the non-fatal reconcile step.
- Compatibility block: T-018, why it is the one check that fails closed, and
  the two live instances of that class it fixes (c_p006, _valid_cron/A-002).
- template.yaml file-tree note now names its four declarative blocks.

Refs trinity-enterprise#89

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(flows): template-declared schedules + the compat fail-open class (ent#89)

- template-processing.md: the `schedules:` reader beside the `credentials:`
  ones, the three deliberate differences (normalized surface, both GitHub list
  paths fenced, creation does not read the catalog's copy), and the
  error-string discipline.
- scheduling.md: new flow 1c — the fourth schedule producer, why the carrier
  is not `template_data`, why non-fatality is the invariant, the falsy-return
  check, and idempotency in all three places.
- agent-compatibility-validation.md: T-018, why it is the one check that fails
  closed, the A-002 cron-authority consolidation, and the c_p006 live instance.
- feature-flows.md: Recent Updates row (required even when the flow docs
  already existed).
- learnings.md: the durable half — a per-item swallow plus a count that ignores
  skips makes a validator's own bug invisible, and persistence replays it.

Refs trinity-enterprise#89

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test: GitHub contents-API contract + live catalog surface for ent#89

Two gaps the /update-tests coverage checklist surfaced:

- Every other create-path-fetch test stubs `_fetch_template_yaml_result`,
  i.e. BELOW the HTTP layer — so a wrong param name (`?ref=` is what pins the
  revision) or a dropped Authorization header would leave them all green while
  the feature silently read the default branch, or read nothing for a private
  repo. That is precisely the R2 failure this fetch exists to prevent, so it
  is now asserted at the wire, including the ent#123 tokenless case sending no
  Authorization header at all.

- The unit suite proves both BUILDERS emit `schedules`/`schedule_errors`; only
  a live call proves the router serves them, that entries are the normalized
  shape, and that a template reporting errors is still LISTED — the
  ent#128/#1835 property, which now also covers the two newly-fenced GitHub
  list paths.

Unit run: 208 passed across the four ent#89-touched unit files.

Refs trinity-enterprise#89

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(flows): sync system-manifest.md with the ent#89 schedule dedupe

/sync-feature-flows caught what the plan had deliberately trimmed: the
`create_schedules()` section documents a function whose behaviour this change
alters. Its docstring block gave no hint that the function is now the SECOND
schedule producer for the same agent.

Adds the name-match skip, why it exists (no UNIQUE(agent_name, name) index and
adding one is a dual-track schema change that would fail on installs already
holding duplicates), that the skip does not overwrite the template's row
including its `enabled` value, and that an unreadable existing set fails open.
Index row now lists the flow too.

Noted, not acted on: `feature-flows.md` Recent Updates carries 65 dated rows
against the ~20 cap #1360 set — pre-existing drift, out of scope here.

Refs trinity-enterprise#89

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(templates): sanitize the create-path fetch's failure reason (ent#89)

`fetch_template_metadata_for_create`'s WARNING interpolated `reason` raw while
its two neighbours on the same call are `_sanitize_for_warning`-wrapped. The
value embeds `str(e)`, and an httpx error message carries the request URL —
i.e. the caller-supplied `owner/repo`. A repo with no `/` skips
`_GITHUB_REPO_PATH_RE` upstream, so control bytes do reach that line.

Bounded at 200 rather than the 80 default: this WARNING exists to be
diagnosable, and an 80-char truncation defeats its purpose.

Raised as a code-consistency item by the /review pass; the CSO diff audit
discarded it as a finding under hard exclusion #9 (log spoofing), so this is
hygiene inside one call, not a security fix.

Tests pin both halves (control-char stripping and the length bound) and both
fail without the change. Also renames a stale `_template_schedule_errors()`
reference in the requirements doc and cites #1945 for the autonomy-clobber
follow-up the doc previously promised without an issue number.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(security): CSO diff audit for ent#89 template-declared schedules

PASS with 1 MEDIUM (LLM cost amplification: declared schedules meeting the
pre-existing `set_autonomy_status` clobber). No CRITICAL, no HIGH. The finding
is an amplification of a pre-existing mechanism — there has never been a
per-agent schedule cap — so it is filed as #1945 rather than changing this PR.

Matches the convention of the 98 reports already tracked here; the file's Trend
section is only meaningful alongside its siblings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: trinity-ability <309458136+trinity-ability@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants