Skip to content

Release: v0.5.0#608

Merged
vybe merged 67 commits into
mainfrom
dev
Apr 30, 2026
Merged

Release: v0.5.0#608
vybe merged 67 commits into
mainfrom
dev

Conversation

@vybe

@vybe vybe commented Apr 30, 2026

Copy link
Copy Markdown
Contributor

Release v0.5.0 — cumulative changes since cli-v0.2.4 (2026-04-09).

Security

Features

Bug Fixes

Refactors

Documentation


Closes #598 #590 #587 #584 #581 #568 #565 #562 #555 #552 #550 #539 #533 #531 #524 #514 #504 #459 #456 #454 #428 #364 #363 #67

Pre-release checklist passed 2026-04-30. Ready for squash-merge.

  • Tag after merge: v0.5.0
  • Triggers: sync-docs-to-vertex.yml

vybe and others added 30 commits April 24, 2026 13:55
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>
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>
#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>
Merges feature/291-webhook-triggers into dev.

- Public POST /api/webhooks/{token} endpoint — no JWT, 202 Accepted,
  rate-limited 10 calls/60s per token, audit-logged
- JWT-auth webhook management: POST/GET/DELETE /api/agents/{name}/schedules/{id}/webhook
- Token rotation, Redis rate limiting (fail-open), context injection with
  prompt-injection framing, partial unique index for O(1) lookup
- 12 integration tests in tests/test_webhook_triggers.py
- requirements.md WEBHOOK-001 entry, architecture.md updated

Closes #291
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>
) (#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>
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>
#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>
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>
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>
…iation

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>
… 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>
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>
…ss (#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>
…#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>
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>
#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.
…cityManager (#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>
…) (#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>
…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>
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>
… (#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>
…ONTEND_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>
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
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).
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>
… (#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>
#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>
* 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>
…ng 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>
pavshulin and others added 29 commits April 28, 2026 16:53
#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>
#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>
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)

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>
Replaces the blank "Start a Conversation" placeholder with up to 6
playbook buttons (or 4 fallback prompts when the agent has none).
Cuts activation energy for new users — Paradigm Life client feedback.

Click behavior:
- Playbook without `argument_hint` → sends `/<name>` immediately.
- Playbook with `argument_hint` → populates input so the user can fill the arg.
- Fallback prompts → populate input.

PublicChat renders the buttons above the input gated on
`userMessageCount === 0` so they coexist with the assistant intro
message and disappear after the first user submit.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Use <picture> element with prefers-color-scheme media queries to serve
white logo on dark mode and black logo on light mode. Adds
docs/assets/trinity-logo-white.svg alongside the existing black variant.

Co-Authored-By: Claude <noreply@anthropic.com>
Switch from <picture> element to GitHub's native #gh-dark-mode-only /
#gh-light-mode-only URL fragment approach, which is more reliably
processed by GitHub's rendering pipeline.

Co-Authored-By: Claude <noreply@anthropic.com>
) (#569)

First slice of the #554 sweep: migrates the 6 operator-queue components
from raw status colors to the semantic tokens introduced in #67/#555.

Files migrated:
  - NotificationsPanel.vue  44 sites — priority/type/status badges, counts
  - QueueCard.vue           10 sites — option buttons, type/priority pills
  - QueueItemDetail.vue     22 sites — same pattern + response box
  - QueueList.vue           18 sites — priority dots/labels, status badge
  - QueueStats.vue          16 sites — priority indicators
  - ResolvedCard.vue         2 sites — resolved status indicator

Token usage:
  status-success → completion / acknowledged / approval option
  status-danger  → critical / urgent priority / pending / reject option
  status-urgent  → high priority
  status-warning → medium priority / question type / pending status
  status-info    → low/normal priority indicator
  accent-purple  → "approval" / "status" type categorical pills

Deferred per the #554 caveat (these need their own design decision
before mass migration):
  - Blue for primary actions (buttons, links) — needs `action-primary`
  - Blue for selected-state highlight — needs `state-selected`
  - Blue for "question" type pill — decorative-categorical
  - Amber for "alert" type — palette-shift vs `status-warning` (yellow)
  - Green for "Acknowledge Selected" bulk button — primary action

Verified via npm run check:tokens (10 tokens valid) and npm run build.

Refs #554

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(frontend): Playwright e2e harness gated on `ui` label (#556)

Phase 2 of #556 (frontend test infrastructure). Adds @playwright/test as a
dev dependency and a minimal smoke-test harness covering login, dashboard,
agents, operating room, and templates pages.

Files:
  src/frontend/playwright.config.js     Chromium-only, baseURL configurable
  src/frontend/e2e/auth.setup.js        Storage-state pattern (login once,
                                        reuse session across specs)
  src/frontend/e2e/smoke.spec.js        4 cross-page smoke tests
  src/frontend/e2e/README.md            How to run + add tests
  src/frontend/.gitignore               Excludes session state + reports
  src/frontend/package.json             test:e2e[:ui|:headed|:update] scripts
  .github/workflows/frontend-e2e.yml    Runs only on PRs with `ui` label

CI strategy: e2e is opt-in via the `ui` label rather than running on every
frontend PR — the workflow stands up the full Trinity docker-compose stack
and adds ~5 min runtime per PR. Backend-only PRs skip it.

Out of scope (separate slices of #556):
  - Vitest unit / component tests (Phase 1)
  - vue-tsc type checking (Phase 3)
  - Visual regression baseline for the design system (added per-component
    in #569 follow-ups)

Local verification: `cd src/frontend && ADMIN_PASSWORD=<pwd> npm run test:e2e`
against a running ./scripts/deploy/start.sh stack. CI is the canonical
verification path.

Refs #556

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

* docs(requirements): add 8.8 Frontend E2E Test Infrastructure entry (#556)

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: vybe <me@evyborov.com>
…568) (#575)

The 192-bit `sig` token minted at share time is already the auth
credential for outbound shared files. Layering the public-chat
`require_email` gate on top required a `session_token` that
`build_download_url` never appended and that the agent has no way
to learn — making file sharing permanently broken for any agent
with `require_email=true`.

Drops the `_agent_requires_email` import, the gate block, and the
`session_token` query parameter from GET/HEAD `/api/files/{id}`.

AST-based regression test pins the new shape so the gate cannot
silently come back.

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

Closes the gap left by SEC-001 Phase 4: the append-only contract was
enforced by SQLite triggers but nothing pruned aged-out rows, so
audit_log grew unbounded.

Adds a daily APScheduler job (`audit_retention_service.py`) that
DELETEs `audit_log` rows older than `AUDIT_LOG_RETENTION_DAYS`
(default 365). Floored at 365 because the `audit_log_no_delete`
trigger refuses younger rows. Disable with
`AUDIT_RETENTION_ENABLED=false`; runs at `AUDIT_RETENTION_HOUR:15`
(default 04:15 UTC, one hour after log archival to spread DB writes).

The prune SQL deliberately uses the same `datetime('now', ?)` form as
the trigger's WHEN clause — this trades architectural invariant #16
(prefer `iso_cutoff()`) for trigger consistency, avoiding
IntegrityError on day-of-cutoff boundary rows. Fixing the trigger to
use ISO-Z form is tracked separately.

Hash chain note: pruning DELETEs entries, breaking the SHA-256 chain
across the cutoff. Verification ranges should stay within retention
by design — documented in the service docstring.

Tests cover: old-only removal, empty-table return, sub-365 floor
rejection, and a boundary-row stress case that fails loudly if the
WHERE clause and trigger ever drift.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Squash-merge #588. Validation: access control correct, feature flow documented, tests in place. Follow-up: requirements.md PUB-007 entry + architecture.md endpoint note.
…577)

Both backend (`main.py:893`) and agent-server
(`docker/base-image/agent_server/routers/info.py:67`) serve health at
the top-level `/health`, not `/api/health`. Curl confirms:
`/health` → 200, `/api/health` → 404.

Closes #565.

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

Hovering a timeline bar now shows three lines:
  Header: Trigger type, status, duration (unchanged)
  Cmd: first 80 chars of the originating message
  Out: first 80 chars of the response (success)
       — OR —
  Error: first 80 chars of activity.error (failure)

Pipeline:
- Backend: task_execution_service writes details.response_preview
  (sanitized_resp[:200]) on the success-completion merge into the
  chat_start activity row. Failed activities already populate
  activity.error via complete_activity(error=...).
- Store: network.js maps details.message_preview /
  details.response_preview / activity.error onto each timeline event.
- Component: ReplayTimeline.vue threads the new fields through
  agentActivityMap and bars, then renders them in the existing
  SVG <title> element. previewLine() collapses whitespace and
  truncates to 80 chars + ellipsis; whitespace-only previews are
  silently skipped so the tooltip stays compact when there's
  nothing useful to show.

Note: SVG <title> is the same primitive the schedule-marker tooltip
already uses, so multi-line behavior is consistent across the
timeline. Mobile devices that don't dispatch hover degrade to the
existing click → execution detail page.

Verified end-to-end: backend write → /api/activities/timeline →
store mapping. Visual rendering of the new tooltip lines should be
eyeballed in the Trinity dashboard timeline view.

Closes #514.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(e2e): make Playwright auth setup actually authenticate (#556)

The harness landed in #571 but its auth.setup.js had two bugs that
prevented every downstream test from being authenticated. Both surfaced
on the first CI run.

  1. Admin-login toggle button text is "🔐 Admin Login" — Playwright's
     getByRole accessible-name match doesn't normalize the emoji prefix
     reliably. Switch to `button:has-text("Admin Login")`.

  2. Saving storageState immediately after the password field is hidden
     captured an empty state (0 cookies, 0 localStorage entries). The
     auth store's `localStorage.setItem('token', ...)` write happens
     async after the form unmounts. Poll until the token actually lands
     in localStorage before saving.

Also tighten supporting selectors:
  - Submit button → `form button[type="submit"]` (no copy dependency)
  - Smoke nav check → `getByRole('link', { name: 'X', exact: true })`

Verified locally against a live ./scripts/deploy/start.sh stack:
  5 passed (5.1s) — auth setup + 4 smoke specs.

Refs #556

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

* fix(e2e-ci): skip first-time setup wizard on fresh DBs

CI was failing the auth.setup because the frontend redirected to /setup —
the first-time setup wizard. On a fresh install:

  1. Migration #19 (setup_completed_backfill) runs *before* admin user
     creation, finds no admin, skips the backfill
  2. _ensure_admin_user creates the admin from ADMIN_PASSWORD env var
  3. Nothing ever sets setup_completed=true → wizard appears

Production avoids this because admin already exists when migrations run.
For CI we explicitly mark setup complete via docker exec right after
backend health.

Also bump the fallback ADMIN_PASSWORD from `ci-test-password` (no upper,
no digit, no special — fails OWASP ASVS 2.1) to `CiTestPassword!1` so
the backend doesn't warn / silently misbehave on startup.

Refs #556

---------

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

Closes the residual finding from the April 2026 UnderDefense
remediation pentest (3.2.1, residual CVSS ~2.1): JWT in the
WebSocket URL leaks into nginx access logs, browser history, and
upstream proxy logs, and the cookie-style same-origin handshake
opens a CSWSH surface.

New flow:
  1. Browser POSTs /api/ws/ticket (JWT in Authorization header).
     Backend mints a 32-byte urlsafe opaque ticket, stores it in
     Redis with a 30s TTL, returns it.
  2. Browser connects to /ws?ticket=<opaque>. Backend atomically
     GETDELs the Redis key (single-use) and resolves to the
     authenticated subject before websocket.accept().
  3. Reconnects re-mint a fresh ticket; the JWT never enters the
     WebSocket URL.

CSWSH is mitigated because POST /api/ws/ticket requires the JWT in
an Authorization header — a malicious page can't mint a ticket on
the victim's behalf without an explicit cross-origin request, which
CORS rejects.

Components:
- services/ws_ticket_service.py: mint + atomic-GETDEL consume.
  Fails closed when Redis is down (mint raises 503; consume
  returns None → /ws closes 4001).
- routers/ws_tickets.py: POST /api/ws/ticket (JWT-authed).
- main.py /ws: drop ?token= path entirely; require ?ticket=.
- frontend (utils/websocket.js + stores/network.js): fetch ticket
  via existing axios bearer-auth defaults, then connect with
  ?ticket=. The two real /ws call sites are the only ones touched
  — useProcessWebSocket.js is dead code (not imported anywhere)
  and AgentTerminal/useVoiceSession use different endpoints.

Scope note: /ws/events keeps ?token=trinity_mcp_xxx for documented
external clients (websocat, wscat scripts). MCP keys are scoped,
named, and revocable so the leak surface is bounded relative to
a JWT.

Tests: 7 unit tests on ws_ticket_service covering mint+GETDEL
single-use, expired/missing/malformed handling, and Redis-down
fail-closed. Live smoke verified protocol end-to-end:
ticket → ws connect → ping/pong; same ticket reused → 403.

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

Frontend equivalent of /test-runner. Existing /test-runner only knows
about pytest; nothing covered the Playwright suite added in #571 / #579.

The skill encodes:
  - Pre-flight checks (backend + frontend health, admin password sanity)
  - npm run test:e2e orchestration with --update-snapshots / --headed /
    --ui / --grep variants
  - Failure classification with concrete recovery commands for the six
    patterns we hit while building the harness:
      A. Stack down (port-zombie diagnostics + Docker Desktop restart)
      B. Stuck on /setup wizard (fresh-DB CI scenario, fix via
         db.set_setting('setup_completed','true'))
      C. Invalid username/password (env drift between shell and container)
      D. Submit button disabled (form precondition issue, not stack)
      E. Auth setup passes but smoke specs land on /login (storageState
         saved before localStorage.token write — known issue fixed in #579)
      F. Visual regression baseline mismatch (intentional vs unintentional
         decision tree)
  - Reminder to add the `ui` PR label so frontend-e2e CI runs

Refs #556

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(voice): tool calls + canvas orb for voice chat (#581)

Implements VOICE-001 tool calling and the animated speaking orb UI.

Backend:
- gemini_voice.py: add _RUN_TASK_TOOL declaration, _execute_and_respond()
  coroutine (asyncio.create_task per call, 30s timeout), _execute_tool()
  delegates to agent_client.task(), _pending_tool_tasks dict on VoiceSession
- voice.py: voice_name param in VoiceStartRequest, 3-level system prompt
  fallback (DB → container file → auto-generate), on_tool_call/on_tool_result
  WS callbacks with platform audit logging

Frontend:
- audio.js: AudioWorklet-first mic capture (blob URL inline), AnalyserNode
  amplitude extraction via getAmplitude() 0–1 float
- useVoiceSession.js: toolName/amplitude state, isToolCalling computed,
  tool_call/tool_result WS message handlers, 30ms amplitude polling
- VoiceOverlay.vue: pure canvas orb — value noise + curl noise particles
  (220, 3 layers), state hue rotation (listening +90°, speaking +210°),
  amber badge during tool_calling, no CDN dependencies

Tests:
- tests/unit/test_voice_tools.py: 12 unit tests covering _execute_tool,
  _execute_and_respond, tool declaration, end_session task cancellation

Fixes #581

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

* fix(voice): rename _send_task→_timeout_task; update VOICE-007 requirements

Fixes overwritten task handle in connect_and_stream (W1 from PR review) and
adds VOICE-007 to requirements.md with correct phase roadmap status (W2).

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Extracts shared upload_service from the Slack adapter and wires it into the authenticated chat endpoint and public chat endpoint so users can attach images and text files directly from the web UI.

- New services/upload_service.py: shared image/file processing pipeline (magic-byte MIME validation, path traversal sanitization, vision block assembly, Docker put_archive, audit logging)
- adapters/message_router.py refactored to delegate to upload_service
- routers/chat.py + routers/public.py: accept optional files[] field
- ChatInput.vue: drag-drop + file picker + preview chips
- ChatPanel.vue + PublicChat.vue: forward files to API
- nginx.conf: client_max_body_size 25m
- 17 unit tests; /cso --diff audit (1 MEDIUM finding: log CRITICAL if python-magic unavailable)

Fixes #364
* sec(files): close .mcp.json RCE-by-config bypass (AISEC-C2, #590)

PUT /api/agents/{name}/files and POST /credentials/inject both accepted
.mcp.json with attacker-controlled JSON. Tool `command:` fields run as
the agent process, so raw injection of MCP server definitions = RCE.

Layer 1 fix (this PR):
- backend ALLOWED_CREDENTIAL_PATHS tightened to {.env, .credentials.enc};
  .mcp.json and .mcp.json.template removed from the user-facing inject
  path. Platform-internal services (template_service, credential_encryption,
  github_pat_propagation) still reach the agent-server endpoint directly
  with its broader allowlist.
- backend update_agent_file_logic gains a defense-in-depth deny check
  before proxying to the agent-server. Mirrors the path_deny list in
  guardrails-baseline.json plus lexical path normalization to defeat
  `..`/`.` traversal attempts.
- agent-server EDIT_PROTECTED_PATHS adds .mcp.json and .credentials.enc.
  Snapshot guard in test_persistent_state_reader refreshed accordingly.

UI feature regression: CredentialsPanel.vue's "Save File" for .mcp.json
and .mcp.json.template now returns 400. Quick Inject .env unchanged.
Layer 2 (#598) restores post-deploy MCP server editing via a structured
endpoint with command/transport allowlists.

Out of scope (deferred):
- Moving runtime config files to non-agent-writable paths (architectural)
- Tightening agent-server's allowlist (breaks platform-internal flows;
  cross-tenant network exposure tracked in #589)
- OAuth token isolation in /proc/1/environ (separate threat-model fix)

Tests: 56 new + refreshed unit tests, including exact AISEC-C2 reproduction
(.mcp.json relative + absolute, .mcp.json.template, .credentials.enc) and
path-traversal coverage. All pass.

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

* test(security): integration tests for #590 AISEC-C2 bypass closure

Hits the live backend to prove the deny-list works end-to-end through
the HTTP layer (the unit tests in tests/unit/ only validated the helper
functions). 42 tests covering:

- Exact AISEC-C2 reproduction (PUT /files + credentials/inject vectors)
- Full path_deny coverage parametrized over 15 protected paths
  (.mcp.json, .mcp.json.template, .credentials.enc, .env*, .ssh/*,
   .aws/*, .gcp/*, .claude/settings*, .trinity/*, .git/*, /opt/trinity/*,
   /etc/claude-code/*)
- Path traversal variants parametrized over 8 inputs
  (../../etc/passwd, content/../.mcp.json, .mcp.json absolute paths,
   . segment normalization)
- Credentials/inject allowlist (.env + .credentials.enc accepted;
  9 disallowed-path variants rejected including subdir/.env, .ENV,
  /.env, mixed-batch rejection)
- Auth required on both endpoints (401 without token)
- Defense-in-depth verification: STOPPED agent's protected-path write
  still returns 403, proving the deny check runs at the backend layer
  before container_reload (not just at the agent-server)
- File-state proof: read .mcp.json before AND after the blocked write,
  assert content is byte-for-byte identical (closes the loop on the
  exploit — 403 alone isn't enough; the file must actually be unchanged)

Also fixes test_credentials.py::test_inject_credentials_multiple_files
which exercised .env+.mcp.json (both 200 pre-#590); now uses .env+
.credentials.enc which is the still-allowed pair.

Run: pytest tests/test_files_guardrail_bypass.py -v

40 passed, 2 skipped against the live backend on dev. The 2 skipped
are agent-not-ready timing flakes in the freshly-created-agent fixture
— both legit-path regressions (.env alone, .credentials.enc alone)
were re-verified manually against a long-running agent and pass.

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>
Co-authored-by:  Pavlo <pash@Mac.home>
Layer 2 of the AISEC-C2 closure. Layer 1 (#590) closed the RCE-by-config
bypass by removing .mcp.json from the inject allowlist; this restores the
legitimate post-deploy MCP server editing flow by gating .mcp.json content
through structure validation.

services/mcp_validator.py (NEW)
- Single-file SOLID: McpValidationError + validate_mcp_config() public API
- Internal class-per-transport (StdioValidator, HttpValidator, SseValidator)
  dispatched via _ENTRY_VALIDATORS_BY_TRANSPORT (Open-Closed: add a transport
  by adding a class + one map entry)
- Closed schema: only mcpServers at root; only command/args/env/url/headers/
  type per entry; rejects unknown fields
- Stdio rules: command in allowlist {npx, uvx, python, python3, node, bun,
  deno, docker}; no path separators; ASCII-only (defeats Unicode homographs);
  args without shell metachars or command substitution; per-runtime inline-
  exec flag block (-c/--eval/-p/eval as first positional)
- HTTP/SSE rules: HTTPS only; no userinfo (@); hostname must NOT resolve
  to private/loopback/link-local (SSRF guard mirroring SEC-179/#179);
  header name allowlist; bounded header count
- Env rules: ${VAR} substring refs allowed (covers Bearer ${TOKEN} pattern);
  refs must match POSIX shape AND not be in RESERVED_ENV_REFS (PATH, LD_*,
  PYTHONPATH, TRINITY_MCP_API_KEY, ANTHROPIC_API_KEY, etc.); literal portion
  scanned for shell metachars + credential patterns from guardrails-baseline
- Reserved server names: `trinity` (auto-injected entry) cannot be clobbered
- Bounded: 64KB content, 32 servers, 64 args, 4096-char env values

routers/credentials.py
- Re-add .mcp.json to ALLOWED_CREDENTIAL_PATHS (path layer)
- Hook validate_mcp_config() into the inject handler before agent-server
  proxy. McpValidationError → HTTP 400 with the validator's specific error
  message in detail (safe to surface — no internal paths)

Frontend (CredentialsPanel.vue)
- No structural changes — existing raw JSON editor works as-is and surfaces
  the validator's error message via err.response?.data?.detail
- Updated placeholder to use context7 (real allowlisted server) instead of
  the now-reserved `trinity` server name

Tests
- tests/unit/test_mcp_validator.py (NEW, 88 tests):
  AISEC-C2 reproduction (/bin/sh, bash, sh) → all rejected
  Server name rules (reserved, invalid chars, length, traversal)
  Stdio: missing command, path separator, Unicode homograph, null byte,
    inline-exec flags per runtime, shell metachars in args
  Env: ${VAR} refs, partial refs, reserved names, command substitution,
    literal secret patterns (anthropic, github, AWS), oversized values
  HTTP/SSE: schemes, userinfo, IMDS/localhost/RFC1918 SSRF, headers,
    Unicode hostnames
  Realistic configs (context7, playwright, uvx) — all accepted
- tests/test_mcp_validator_endpoint.py (NEW, 22 tests against live backend):
  AISEC-C2 still blocked (now via content validator, not path)
  Legit configs accepted end-to-end
  11 parametrized bypass attempts → all 400
  Mixed-batch atomicity (.env + evil .mcp.json rejects whole batch)
  .mcp.json.template stays blocked at path layer
- Updated existing tests:
  test_credential_inject_allowlist.py: .mcp.json now in path allowlist;
    .mcp.json.template stays out
  test_files_guardrail_bypass.py: AISEC-C2 inject test asserts new
    content-validator error message instead of path-rejection message
  Mixed-batch test updated for new atomicity semantics

150 unit tests + 56 integration tests pass against live backend.
6 manual end-to-end checks confirm: AISEC-C2 → 400, npx → 200,
Bearer ${TOKEN} → 200, SSRF → 400, trinity reserved → 400, .env → 200.

Performance: validation is in-memory string ops + 1 DNS lookup per
http/sse server (negligible). No DB, no I/O beyond what credentials/inject
already does.

Bypass surface explicitly guarded:
- absolute path commands (/usr/bin/npx)
- backslash separators
- Unicode homographs in command names
- null bytes in command/args
- shell metacharacters anywhere
- command substitution ($() and backticks)
- inline-exec flags per runtime
- partial ${VAR} smuggling past reserved-name check
- reserved env var references (PATH, LD_PRELOAD, etc.)
- IMDS / localhost / RFC1918 SSRF via http/sse url
- HTTP downgrade (https-only)
- userinfo URL smuggling
- Unicode hostnames
- header smuggling via non-allowlisted names
- closed schema (no future MCP spec field surprises)
- 64KB content cap, 32 servers max

NOT a complete fix (honest about limits): even with the runtime allowlist,
`npx <evil-package>` still runs attacker code via npm. Layer 2 blocks
shell-injection patterns and the AISEC-C2 reproduction; Layer 3 (sandbox
MCP execution + OAuth token isolation) is a separate threat-model fix.

Stacked on PR #599 (#590 Layer 1).

Co-authored-by:  Pavlo <pash@Mac.home>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…#593)

Closes #584. Adds the missing setter for the Slack DM-default routing
flag — until now it was auto-set on the first agent ever connected and
unchangeable through any UI or API; the only workaround was direct DB
edit. Multi-agent workspaces were stuck routing every DM to the first
agent forever.

Backend:
- db/slack_channels.py — new `set_dm_default(team_id, agent_name)`
  uses single-tx clear-then-set inside one BEGIN/COMMIT to avoid
  any window with two defaults (schema has no exclusivity constraint).
- routers/slack.py — `PUT /api/agents/{name}/slack/channel/dm-default`,
  owner-gated via `can_user_share_agent`, audit-logged via
  `AGENT_LIFECYCLE/slack_dm_default_changed` (details: team_id,
  workspace_name, previous, new_default). Idempotent: returns
  status=unchanged when caller is already the default.
- routers/slack.py — DELETE /slack/channel now returns **409** if the
  target is the workspace's DM default and other agents are bound.
  When the target is the only agent, unbind is allowed (clean cascade).
  Promoting a sibling first is the only path past the guard — keeps the
  workspace from ending up with zero DM default.
- routers/slack.py — GET response includes `workspace_agent_count` so
  the UI can decide whether to disable Unbind.

Frontend:
- SlackChannelPanel.vue — replaces the read-only `(DM default)` label
  with a "Make default" button (when not default) or a "✓ DM default"
  badge (when default). Both carry a hover tooltip explaining what
  DM default means. Unbind button is disabled with an explanatory
  tooltip when this agent is the DM default and the workspace has
  other bound agents.

Tests (10 new, all pass):
- test_slack_dm_default.py — set_dm_default contract: returns False
  when agent unbound, sets exclusively, idempotent, isolated per
  workspace, doesn't touch siblings. unbind_agent contract: pure
  delete (does NOT auto-promote), works on non-default + last-agent
  paths, unknown agent returns False.

Live verification on dev:
- DM routing flips immediately after `PUT /dm-default` (no restart,
  no cache — `get_slack_dm_default_agent()` queries DB on every call)
- Channel @mention routing unaffected (channel binding wins over DM
  default)
- 409 surfaces with explanatory message when blocked
- audit_log row written with correct actor/details

Docs:
- feature-flows/slack-channel-routing.md — endpoint table, UI state
  description, and DM-default semantics updated
- feature-flows.md index — Recent Updates row added
- tests/registry.json — new entry

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

Slice 2 of the #554 sweep. Migrates the /monitoring view (Health tab)
from raw status colors to semantic tokens.

Migrations (all clear status semantics):
  - Service-active badge (green)
  - 5 summary cards: healthy / degraded / unhealthy / critical (extra)
  - Active alerts banner background and heading
  - Per-alert priority icon (urgent vs warning)
  - "N issues" count for unhealthy agents
  - getStatusBgClass / getStatusTextClass / getStatusBadgeClass — all 5
    statuses route through status-success / status-warning / status-danger
    (with 200/700 shades for the "critical" emphasis tier)

Deferred (10 raw blue refs remain):
  - "Auto-refresh" toggle button — selected-state highlight, needs an
    `action-primary` or `state-selected` token (per #554 caveat)
  - "Check All" admin primary action button
  - Per-row "trigger health check" hover/focus styling

These are the same categories deferred in #569 (operator queue slice 1).
A future PR can introduce `action-*` tokens to cover them across the
whole codebase.

Tests:
  - Adds /monitoring smoke spec to e2e/smoke.spec.js (5 → 6 tests)

Visual regression baselines for /monitoring will land in a follow-up PR
once the cross-platform snapshot capture path is in place (Linux runner
font rendering ≠ macOS, so locally captured PNGs would always fail in CI).

Refs #554

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

/api-keys page (top-level route). 19 status-color refs migrated:
    - Active / Revoked badges and indicators        → status-success / status-danger
    - Revoke / Delete buttons                       → status-warning / status-danger
    - "Connect to MCP Server" info banner           → status-info (banner + icon + code blocks)
    - "Copy before closing" warning banner          → status-warning
    - Success-state checkmarks (modal, copy button) → status-success
    - "Agent" scope tag                             → accent-purple

  Deferred:
    - Indigo primary action buttons ("Create API Key", "Create", "I've copied")
      — same `action-primary` token gap as #569 / #595
    - "System" scope tag (orange) — needs accent-orange (or brand-system)
      token; out of scope for status-only sweep

Adds a tag-and-grep model so the suite can grow without coupling local-dev
needs to CI runtime cost.

  Tag       Runs in CI?  Purpose
  ─────────────────────────────────────────────────────────────────
  @smoke    yes          Cross-page health, must always pass
  @visual   no (local)   Screenshot baselines, deferred until #596
  @Interactive no (local) Multi-step flows, local-only until stable

  Changes:
    - smoke.spec.js — every test now starts with @smoke
    - new spec: @smoke api keys page loads
    - package.json — adds `npm run test:e2e:smoke` (--grep @smoke)
    - frontend-e2e.yml — CI now runs the smoke subset only
    - e2e/README.md — tag convention documented

  Local default (`npm run test:e2e`) still runs everything; CI now matches
  what `npm run test:e2e:smoke` runs locally.

Refs #554 #556

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Migrate Tailscale GH Action from authkey to OAuth client credentials
- Fix pytest combined-run module conflicts: pre-register backend models,
  routers namespace, and agent_server shim in conftest before collection
- Fix test_slack_watchdog adapter stub to preserve package __path__ (avoids
  "adapters is not a package" in combined runs)
- Update validate-{architecture,config,schema} skills to comment on existing
  open issues instead of creating duplicates
- Add CSO audit report for #587 (clean, 0 findings)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@vybe
vybe merged commit 7333fab into main Apr 30, 2026
3 checks passed
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.

feat: structured MCP-server config endpoint (Layer 2 follow-up to #590)

6 participants