Skip to content

Setup improvements#5

Merged
vybe merged 1 commit into
mainfrom
setting-up-project-improvements
Jan 16, 2026
Merged

Setup improvements#5
vybe merged 1 commit into
mainfrom
setting-up-project-improvements

Conversation

@dolho

@dolho dolho commented Jan 15, 2026

Copy link
Copy Markdown
Contributor

Utilize the modern docker compose instead of legacy docker-compose. Allow configuring frontend port via env variables

d of legacy docker-compose. Allow configuring frontend por via env variables
@dolho
dolho requested a review from vybe January 15, 2026 14:11
@vybe
vybe merged commit 8310a83 into main Jan 16, 2026
oleksandr-korin added a commit that referenced this pull request Jan 19, 2026
Test Results:
- T3.1: Human approval (approved) ✅
- T3.2: Human approval (rejected) with on_error:skip_step ✅
- T3.3: Approval timeout ⚠️ (not implemented - needs scheduler)
- T3.4: Approval with artifacts/context ✅

Key Findings:
- Approval API works: GET /api/approvals, POST .../approve, POST .../reject
- Rejection causes step to FAIL by design (use on_error:skip_step)
- Timeout enforcement NOT implemented (deadline set but not checked)
- Approval description can include dynamic context

Known Limitations:
- Issue #5: Approval timeout requires scheduler to enforce
- Issue #6: Rejection = failure (by design, documented)

Running Total: 11/22 tests passing (50%)
Refs: PROCESS_ENGINE_ROADMAP.md Phase 1
oleksandr-korin added a commit that referenced this pull request Jan 19, 2026
Test Results:
- T3.1: Human approval (approved) ✅
- T3.2: Human approval (rejected) with on_error:skip_step ✅
- T3.3: Approval timeout ⚠️ (not implemented - needs scheduler)
- T3.4: Approval with artifacts/context ✅

Key Findings:
- Approval API works: GET /api/approvals, POST .../approve, POST .../reject
- Rejection causes step to FAIL by design (use on_error:skip_step)
- Timeout enforcement NOT implemented (deadline set but not checked)
- Approval description can include dynamic context

Known Limitations:
- Issue #5: Approval timeout requires scheduler to enforce
- Issue #6: Rejection = failure (by design, documented)

Running Total: 11/22 tests passing (50%)
Refs: PROCESS_ENGINE_ROADMAP.md Phase 1
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 May 5, 2026
* docs(planning): add Session tab design — --resume-default chat surface

Adds docs/planning/SESSION_TAB_2026-04.md, the comprehensive plan for a
new "Session" tab living alongside Chat. Sessions reattach to their own
Claude Code JSONL via --resume, preserving tool memory, mid-skill state,
and reasoning state across turns.

Plan covers:
- UI design (tab placement, multi-session model, +New Session, Reset memory)
- Data model (agent_sessions / agent_session_messages — parallel to chat)
- Backend architecture (separate router, single shared change to
  task_execution_service for persist_session plumbing)
- Phased rollout (foundation → backend → frontend → hardening → GA)
- Edge cases & failure-mode lessons baked in from a prior local spike
  (parser bug, --no-session-persistence dependency, cold-turn detection,
  port allocation)
- Test plan including the cross-session contamination test for
  Anthropic claude-code#26964
- Retention/cleanup policy, observability, security checklist
- Local-first workflow: implementation runs entirely on this branch
  until validation passes; only then does the standard SDLC engage
  (issue, push, PR)

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

* feat(db): add agent_sessions + agent_session_messages tables

Phase 1.1 of the Session tab plan (docs/planning/SESSION_TAB_2026-04.md).
Schema definitions go in db/schema.py for fresh installs; the matching
idempotent migration agent_sessions_tables in db/migrations.py upgrades
existing databases.

The schema mirrors chat_sessions / chat_messages but is strictly parallel
— no foreign keys, no shared columns, separate index namespace. Three
fields are unique to the session model:

- agent_sessions.cached_claude_session_id — the Claude Code session UUID
  the next turn will pass to ``--resume``
- agent_sessions.consecutive_resume_failures — drives the resume-failure
  fallback (Phase 2.2)
- agent_session_messages.cache_read_tokens — observability for whether
  Anthropic's prompt cache engaged

CASCADE on session delete cleans up message rows automatically.

Verified locally: backend restart applies the migration cleanly, tables
have 15 columns each with correct types/defaults/PKs, all four indexes
created, second restart confirms idempotency.

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

* feat(db): add SessionOperations for Session tab persistence

Phase 1.2 of the Session tab plan (docs/planning/SESSION_TAB_2026-04.md).

- Adds AgentSession and AgentSessionMessage Pydantic models in db_models.py
  with the new fields the Session tab needs beyond ChatSession/ChatMessage:
  cached_claude_session_id, last_resume_at, consecutive_resume_failures on
  the session row, and cache_read_tokens + claude_session_id on each message.

- Creates db/sessions.py with a SessionOperations class mirroring the
  ChatOperations shape: create_session, get_session, list_sessions,
  delete_session, add_session_message, get_session_messages, plus the
  Claude UUID cache helpers (get/update/clear_cached_claude_session_id)
  and resume health helpers (mark_resume_failure, mark_resume_success).

- Wires the new ops into the DatabaseManager facade alongside the
  existing _chat_ops, with one delegating method per public operation.

No router, no agent-server change, no frontend yet — those land in later
phases. Tables agent_sessions and agent_session_messages already exist
from the prior schema commit.

* feat(session-tab): backend foundation for --resume-default Session surface

Phases 1.3 through 1.7 of the Session tab plan
(docs/planning/SESSION_TAB_2026-04.md). Pure backend / agent-server work
behind a flag — no UI surface yet, no behavior change to Chat or any
existing /task caller.

Agent server (base image):

- Stream-json parser fix (Appendix B). Both parse_stream_json_output and
  process_stream_line now recognize {"type":"system","subtype":"init"}
  for session_id capture, with the result event as a fallback when init
  was missed (truncated streams). The legacy bare-init shape is
  intentionally rejected. This is the same bug that would have made
  Session caching corrupt on every cold turn.

- Same bug in execute_headless_task's permission-mode validation site:
  the check matched the wrong shape, so permission_mode_validated never
  flipped to True and the protective kill-on-misconfigured-permission
  path silently failed open. Now uses type=system + subtype=init.

- New persist_session flag threaded through ParallelTaskRequest →
  routers/chat.py → AgentRuntime ABC → ClaudeCodeRuntime.execute_headless
  → execute_headless_task. When True, --no-session-persistence is
  omitted so the JSONL is written and the next turn's --resume can find
  it. --session-id is still passed for unique cold-turn namespace.
  Default False keeps every existing caller stateless.

- gemini_runtime accepts the parameter for ABC parity and ignores it
  (Gemini CLI has no resume).

Backend:

- task_execution_service.execute_task now accepts persist_session: bool
  = False and threads it into the agent payload. All existing callers
  (Chat, schedules, MCP, fan-out, webhooks) keep today's behavior; only
  the future routers/sessions.py (Phase 2) opts in.

- settings_service.is_session_tab_enabled() — feature flag resolving
  system_settings.session_tab_enabled → SESSION_TAB_ENABLED env →
  False. Module-level convenience function exposed.

Tests (run inside trinity-backend container — Python 3.11):

- tests/unit/test_session_operations.py — 9 tests against an isolated
  SQLite DB exercising the full SessionOperations CRUD plus the cached
  claude session UUID lifecycle and resume failure / success counters.

- tests/unit/test_claude_code_session_id_parser.py — 8 tests covering
  both parsers (batch + streaming): system/init recognition, result
  fallback, init-wins-over-result, legacy bare-init rejection, and a
  source-level regression guard for the permission-mode validation
  fix.

- tests/unit/test_session_persistence_flag.py — 8 tests pinning the
  contract: signatures across the runtime ABC, ParallelTaskRequest,
  agent chat router, execute_headless_task, and
  task_execution_service.execute_task. Includes the gating regex check
  on --no-session-persistence and a live signature import to catch
  drift AST parsing alone would miss.

Total: 25 passing tests covering every touchpoint of Phase 1.

Base image (trinity-agent-base) rebuilt to embed the agent-server
changes; existing agent containers will pick them up on next recreate.

* feat(session-tab): backend turn endpoint for --resume-default Session surface

Phase 2 of docs/planning/SESSION_TAB_2026-04.md. Six endpoints under
/api/agents/{name}/session{s,...} that mirror routers/chat.py's auth
model and TaskExecutionService usage but persist to the parallel
agent_sessions / agent_session_messages tables and request
persist_session=True on every turn so each call reattaches via
`claude --print --resume <uuid>`.

Surface gated on is_session_tab_enabled() — flag-off default returns
404 from every endpoint.

  POST   /api/agents/{name}/session                  create row
  GET    /api/agents/{name}/sessions                 list (per-user)
  GET    /api/agents/{name}/sessions/{id}            session + messages
  POST   /api/agents/{name}/sessions/{id}/message    THE turn
  POST   /api/agents/{name}/sessions/{id}/reset      clear cached uuid
  DELETE /api/agents/{name}/sessions/{id}            delete row + msgs

Spike-pitfall defenses baked into the turn endpoint:

- L3 (first-turn-has-no-session-id): the agent_sessions row is created
  server-side via POST /session BEFORE the turn endpoint ever calls
  execute_task. No frontend-first model.
- L2 (cold turn writes empty JSONL): persist_session=True is passed
  unconditionally — Phase 1.4 already wired the flag through the agent
  stack; Phase 2 just promises to set it on every turn.
- L1 (parser misses system/init): trust result.session_id directly —
  Phase 1.3 fixed the parser. Scenario A confirms the captured UUID is
  the real Claude UUID end-to-end.

Phase 2.2 resume-failure fallback: when execute_task returns "no
conversation found" on a turn that had a cached UUID, clear the cache,
mark_resume_failure, and retry once with resume_session_id=None. Logs
event=session_resume_fallback with the stale UUID and consecutive
failure count. Anthropic #39667 (cleanupPeriodDays) and #53417 (CLI
upgrade) both produce this signal.

Phase 2.3 Redis lock: SET NX EX per (agent, claude_uuid) with 5-min TTL
and Lua-script release. Async poll loop (250ms tick) so the event loop
stays free during contention. Cold turns skip the lock (no JSONL to
corrupt). Hard 30s wait ceiling — beyond that the contender gets HTTP
429 with retry hint. Mitigation for Anthropic #20992 (concurrent
--resume JSONL writes corrupt the file).

Per-user ownership at the row layer: even agent owners cannot read or
send into another user's session (E6 isolation in the design doc).
Returns 404 for ownership failures so we don't leak session-id existence.

Tests (tests/integration/test_session_turns.py, run inside
trinity-backend container with docker.sock mounted for testfix
recreation + JSONL surgery in Scenario C):

  Scenario A: 3-turn happy path — same Claude UUID across turns
  Scenario B: turn 2 recalls a secret from turn 1, no text-replay
  Scenario C: JSONL deletion mid-session triggers fallback + recovery
  Scenario D: concurrent POSTs serialise via Redis lock
              (asserts finish_gap ≈ winner_work_time, NOT total wall)
  Scenario E: switching sessions A → B → A preserves A's UUID

5 passed in 54.5s against the live agent-testfix container (recreated
onto the rebuilt base image first per L4 in the plan). Phase 1's 25
unit tests still pass — no regressions.

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

* feat(session-tab): frontend Session surface

Phase 3 of docs/planning/SESSION_TAB_2026-04.md. Adds the new "Session"
tab in AgentDetail, gated on the is_session_tab_enabled() platform flag
so it stays invisible until explicit opt-in (default off).

Backend prerequisite — routers/settings.py:

- GET /api/settings/feature-flags exposes a curated allowlist of UI-
  relevant flags to any authed user. The existing /api/settings/{key}
  endpoint is admin-only and would block non-admin frontends from even
  knowing whether to render the Session tab. The new endpoint reads
  through services.settings_service.is_session_tab_enabled() so the
  resolution order (DB → env → False) stays in one place.

Frontend:

- src/frontend/src/stores/sessions.js — Pinia store wrapping the six
  /api/agents/{name}/sessions* endpoints with per-agent state isolation
  and the feature-flag cache. Optimistic user-message insert with
  rollback on send failure.

- src/frontend/src/components/SessionPanel.vue — structural copy of
  ChatPanel reusing ChatMessages + ChatInput + ModelSelector. Differs
  from Chat in three places per the design doc:
    * Sends bare user_message to POST .../sessions/{id}/message — no
      buildContextPrompt text-replay (the agent already has working
      memory via --resume).
    * "Reset memory" button + confirm modal that clears the cached
      Claude UUID without deleting the message log (Phase 3.4).
    * Per-session selector subtitle: turn count, context % used,
      cached-memory dot (emerald/gray), and consecutive_resume_failures
      indicator (Phase 3.5).
  Lean cut for first-visible-surface: voice mic, file upload, and SSE
  dynamic status labels are deferred — those need backend extensions
  (file payload on the turn endpoint, async_mode + SSE on the same).

- src/frontend/src/views/AgentDetail.vue — new Session tab inserted
  between Chat and Dashboard/Schedules, gated on
  sessionsStore.sessionTabEnabled. Layout sites that previously
  branched on activeTab === 'chat' now use a shared isFullscreenTab
  computed so Chat and Session both get the input-pinned-to-bottom flex
  layout. ?tab=session deep-link allowlist updated.

- src/frontend/e2e/session-tab.spec.js — Phase 3.6 Playwright spec.
  Marked @Interactive (not @smoke) because each run makes one real
  Claude API call (~10–60s). Snapshots the prior flag value in
  beforeAll, force-enables for the run, restores in afterAll so a
  failed run doesn't leave the platform with the flag dirty. Three
  cases:
    * tab is hidden when flag is off
    * tab appears, "+ New Session" → send turn → reply visible →
      Reset memory modal opens + closes
    * Chat tab still works after Session interaction; switching back
      preserves Session state

Visually verified in the live dev server: tab renders in correct
position, header layout matches Chat's structure, empty state and
placeholder copy match the design doc, "Reset memory" only shown when
an active session exists, full-viewport flex layout pins input to
bottom.

Phase 1 + Phase 2 work behind this change is unchanged: 25 unit tests
+ 5 integration tests still green.

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

* feat(session-tab): hardening + observability — cleanup service, contamination gate, docs

Phase 4 of docs/planning/SESSION_TAB_2026-04.md. Closes the JSONL
disk-growth loop, validates the GA-blocking cross-session contamination
hypothesis empirically, and lands architecture.md / feature-flows
documentation so the surface is discoverable.

Phase 4.3 — cross-session contamination GA gate (the load-bearing one):

- tests/integration/test_session_cross_contamination.py exercises the
  Anthropic #26964 hypothesis end-to-end. Plants a randomly-generated
  secret token in session A with explicit "do not echo" framing, asks
  session B (different UUID, same agent, same cwd) to recall the token.
  Hard-fails if the exact token leaks; soft-fails on partial-prefix
  recall (PURPLE-DRAGON without the random suffix would only be
  knowable from A's JSONL, not from training).
- PASSED in 9.5s on the current Claude Code version → shared-cwd model
  is safe → Phase 5 rollout unblocked. Test stays in the suite as the
  per-version regression guard.

Phase 4.2 — JSONL cleanup service:

- services/session_cleanup_service.py runs a 6h periodic sweep that
  diffs every running agent's
  ~/.claude/projects/-home-developer/<uuid>.jsonl set against
  db.list_active_claude_session_ids(agent) and reaps orphans whose
  mtime is older than the 1h race guard. Race guard prevents the
  cold-turn-vs-cleanup window where a brand-new JSONL exists on disk
  before the backend has updated cached_claude_session_id.
- Same service exposes a synchronous reap_jsonl(agent, uuid) helper
  called best-effort from routers/sessions.py reset/delete handlers so
  the user-perceived disk-reclaim latency is sub-second. Never raises;
  failures are logged and the periodic sweep is the safety net.
- Implementation uses execute_command_in_container — the same primitive
  git_service / ssh_service / scheduler pre-check / agent terminal use.
  No new agent-server endpoint, no base-image rebuild.
- New db.list_active_claude_session_ids(agent) facade method backed by
  SessionOperations.list_active_claude_session_ids querying every
  agent_sessions row whose cached_claude_session_id is non-null for the
  agent.
- main.py wires startup (staggered +7.5s after cleanup_service to
  offset Docker hits) and clean shutdown.
- tests/integration/test_session_cleanup.py: reset reaps synchronously,
  delete reaps synchronously, periodic sweep keeps the active JSONL,
  reaps an aged orphan, respects the 1h race guard for fresh orphans.

Phase 4.4 — architecture.md updates:

- Background Services table gets a Session Cleanup row.
- New "Session Tab" subsection in API Endpoints documenting all six
  /api/agents/{name}/sessions* routes including the per-user ownership
  rule (404 not 403) and the resume-failure fallback / Redis lock.
- New /api/settings/feature-flags row.
- New agent_sessions / agent_session_messages DDL block in Database
  Schema, with the three Session-specific fields called out
  (cached_claude_session_id, consecutive_resume_failures,
  cache_read_tokens, claude_session_id audit).

Phase 4.5 — feature-flows/session-tab.md vertical slice:

- Full path from UI → API → DB → Side Effects with the JSONL lifecycle
  table, the spike-pitfall defense map (L1/L2/L3/#20992/#26964), the
  error-handling matrix, and the complete test catalog with the docker
  run command for the integration suite.
- feature-flows.md index updated (Recent Updates row + Chat & Sessions
  section entry).

Test totals: 25 unit + 9 integration = 34 tests, all green. Phase 4.3
serves as both the GA gate and the per-Claude-version regression guard.

Phase 4.1 (cache_read_tokens UI surfacing) deferred — the column is
already populated by the Phase 2 turn endpoint; surfacing is a minor
observability follow-up that doesn't block Phase 5.

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

* feat(session-tab): tag Session-tab turns with triggered_by="session" and a gold badge

Previously Session-tab turns went into schedule_executions with
triggered_by="chat", so the Tasks tab couldn't tell them apart from
the Chat tab. The user-visible signal was that every Session turn
showed up under the sky-blue "chat" badge.

Backend (routers/sessions.py): both call sites that invoke
task_execution_service.execute_task — the cold/resume turn and the
resume-failure fallback retry — now pass triggered_by="session".
Existing rows are unchanged; the cutover is per-write.

Frontend (TasksPanel.vue): adds a "Session" option between "Chat" and
"Manual" in the trigger filter dropdown, plus an amber/gold badge
branch (bg-amber-100 dark:bg-amber-900/30 text-amber-700
dark:text-amber-300) — visually distinct from "paid" (bright yellow)
and from the sky-blue "chat" badge.

triggered_by is a free-form TEXT column (no enum constraint at the DB
or service layer), so adding "session" as a new value doesn't require
any migration or downstream consumer updates. Filter, badge, audit
log, activity stream, and dashboards all just see another value and
display it; nothing has to know about it explicitly.

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

* fix(session-tab): correct context-window accounting + raise frontend turn timeout

Five interrelated fixes from manual testing — all about the per-turn
"context %" metric being misleading and the browser timing out before
long-running session turns finished.

1) Agent server (docker/base-image/agent_server/services/claude_code.py)
   process_stream_line's `result` event handler used to overwrite
   metadata.input_tokens, cache_read_tokens, and cache_creation_tokens
   with the values from result.usage. Those values are CUMULATIVE
   across every internal API call the turn made (Claude Code packs
   tool-use loops into a single user turn that maps to N internal API
   calls). For an 18-iteration turn each reading the same 70K cached
   prefix, result.usage.cache_read_input_tokens = 18 * 70K = 1.26M
   tokens — billing-cumulative, not the prompt size of any single call.
   Overwriting per-message values with that aggregate made our
   context-window-pressure metric grow far beyond the 200K limit even
   when no individual API call was anywhere close to the wall.

   Fix: result handler now only extracts model-level facts (cost,
   duration, num_turns, session_id, error info, modelUsage.contextWindow).
   Per-API-call usage stays in the per-assistant-message handler, where
   the LATEST message's values represent the FINAL API call's prompt
   size — exactly what determines whether the next turn will fit.

   Also added a per-message usage-extraction block to the assistant
   branch of process_stream_line (it previously had no usage extraction
   at all, relying entirely on the result handler — which made my
   first attempt at this fix produce zero values). parse_stream_json_output
   already had the equivalent block (lines 211-215).

   Base image rebuilt; agent-testfix recreated onto the new image
   (image sha 0a1e20b40da1).

2) Backend (services/task_execution_service.py)
   Replaced `context_used = metadata.input_tokens` with
   `cache_read + cache_creation` (with input_tokens fallback when
   caching isn't engaged). input_tokens is sometimes the disjoint
   fresh value and sometimes inflated by the agent server's
   modelUsage.inputTokens override on tool-call turns. cache_read
   and cache_creation come straight from Anthropic's usage object
   and (post agent-server fix) are reliable per-call values that
   monotonically reflect the cached conversation prefix.

3) DB (db/sessions.py)
   total_context_used is now a HIGH-WATERMARK (MAX of prior + new),
   not the latest value. Per-turn context naturally oscillates by ~2x
   between text-only and tool-call turns; the watermark gives users
   a stable monotonic upper bound on session pressure that only goes
   up.

   Capped the watermark at total_context_max as a safety belt against
   any future agent-server bug that emits cumulative-billing token
   counts. Genuine per-call peaks should never exceed the model's
   context window — if they do, that's an accounting error not a
   real overflow, and the UI should display 100% rather than 648%.

4) Frontend (stores/sessions.js)
   Bumped the Axios timeout on the session turn endpoint from 305s
   (~5 min) to 7260s (= TIMEOUT-001 cap of 7200s + 60s slack). The
   session turn endpoint is synchronous and may legitimately run for
   the agent's full execution timeout. With the previous 305s ceiling
   the browser threw a misleading "failed" toast on tool-heavy turns
   that ran longer; the response still landed in the DB and the UI
   recovered after a page refresh, but the user saw a phantom error.

Verified end-to-end with a 6-turn mixed sequence (text + tool-call):
per-call cache_read now reports ~11636 on text-only turns and ~18000
on tool-call turns (one extra round-trip's worth) instead of the
previous bogus 1,257,915 on tool-heavy turns. Watermark grows from
18073 to 18429 across 6 turns — monotonic, no oscillation, real
per-call peaks.

Existing inflated session rows (the bogus 648% / 100% sessions from
before this fix) stay as-is — the watermark cap stops them growing
further but the historical max is permanently stored. New sessions
created after this commit are accurate.

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

* feat(session-tab): Phase 5.1 — context warnings, stdout-race recovery, limitations doc

Bundles the round-of-decisions outcomes from the Phase 5 open-questions
discussion. Three code changes, three deferrals documented, one item
gated on a manual test before shipping.

Frontend (SessionPanel.vue) — context-window pressure warnings.
Buckets driven by the active session row's watermark divided by
total_context_max:
  < 75%  : nothing
  75–89% : subtle gray "Session context: X% — heavy" hint
  90–99% : amber banner with "Reset memory" suggestion
  ≥ 100% : red banner warning the next turn may fail or trigger
           memory-loss fallback
No hard send-block at 100% — fallback path stays as the safety net.
Computed values gracefully handle a brand-new session (no row yet).

Agent server (claude_code.py) — stdout pipe race soft recovery. When
a child subprocess inherits Claude Code's stdout, the final result
event line can be lost ("I/O operation on closed file" + "Reader
thread(s) stuck after process exit"). Previously surfaced as 502 with
the misleading "infrastructure failure; retry the task" message even
though the assistant had completed its work and accumulated text into
response_parts. The reply was sitting in the JSONL on disk untouched;
only the closing-stats line was lost in the pipe.

  Fix: at the empty-result classification site, if response_parts has
  accumulated assistant text, log a warning and fall through to the
  success path instead of raising. cost_usd / duration_ms stay None
  for these recovered turns (we don't know what they were); the backend
  records the execution as success with null cost rather than a
  misleading FAILED. Hard failure path stays for the truly empty case
  (no response_parts content).

  Base image rebuilt; agent-testfix recreated onto image
  305731fc34e0. The user's last "comprehensive report" turn that
  produced 67KB of content but threw a phantom error in the UI would
  now succeed cleanly.

Docs (feature-flows/session-tab.md) — Known limitations section. Five
entries that need to make it into the user-facing docs at Phase 5.3:
  1. Voice mic not wired into Session (deferred — Chat tab for voice)
  2. Per-message file upload not yet supported on Session (Phase 5.2)
  3. Agent restore from backup may require fresh sessions (separate
     platform-level issue — workspace volumes not in backup script)
  4. Long Session turns may surface phantom errors in browsers (Axios
     7260s ceiling vs. browser/OS sleep — refresh recovers)
  5. Stdout pipe race recovery is best-effort (recovered turns will
     show null cost / duration in Tasks tab)

Round-of-decisions outcomes:
  ✅ #1 — UI thresholds shipped here, stdout race fixed here
  ⏳ #2 — subscription-change cache clear (E7) — gated on manual
         §5.5 test before shipping the proactive clear
  ⏭ #3 — backup/restore (E9) — separate platform-level issue
         later; documented in §Known limitations
  ⏭ #4 — admin JSONL UI access — deferred follow-up
  📋 #5 — file upload parity — Phase 5.2
  ⏭ #6 — voice on Session — deferred + documented limitation

The §5.5 E7 verification test was added to tests/manual/session-tab/README.md
which is tracked locally only (per the testing-kit precedent). Decision
on the proactive cache-clear hinges on its outcome.

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

* feat(session-tab): Phase 5.2 — file-upload parity with Chat

The Session tab's input had drag-drop and the paperclip button (inherited
from ChatInput), but the second arg from ChatInput's submit event
(the files array) was being parameter-discarded in SessionPanel.vue
and never reached the backend. Result: users uploaded a file, sent
their message, and the agent reported "I can't see any attached file
in this conversation" — the upload silently dropped.

Closes the parity gap by mirroring routers/chat.py's exact upload
handling. No new behavior — same limits (3 files, 5 MB each, 5 MB per
image, 20 MB total image budget per WEB_MAX_*), same image-vs-non-image
split, same prompt-line append.

Backend (routers/sessions.py)
  - SessionMessageRequest: new optional `files` list (same shape as
    ParallelTaskRequest.files / WebFileUpload — name, mimetype, size,
    data_base64).
  - Turn endpoint: when body.files is set, decode each via
    services.upload_service.decode_web_file, then run the same
    process_file_uploads() helper Chat uses. Non-images get written
    into the agent workspace via Docker put_archive; images come back
    as already-decoded vision-block dicts in image_data.
  - Append the file_descs lines to a new `effective_message` so the
    agent prompt contains "[File uploaded by X]: name (size) saved to
    path" references inline. Persisted user message stays as the
    original body.message — visible chat log reads naturally; the
    agent sees the file references.
  - Pass image_data to execute_task as `images=` so vision blocks land
    on the next API call. Both the resume call and the cold-retry
    fallback receive the same effective_message + images (files were
    already written to the workspace before the first attempt; cold
    retry just re-references them).
  - 502 from process_file_uploads' all_writes_failed bubbles up
    cleanly; agent-not-found pre-check returns 503.

Frontend (stores/sessions.js)
  - sendMessage() accepts a `files` array in opts; included in the
    POST body when non-empty. Optimistic user-message insert stays
    text-only (the chat log preview doesn't need to render
    file chips).

Frontend (SessionPanel.vue)
  - onSubmit() now takes both args from ChatInput's submit event.
    Allow-empty-text-with-files is permitted (matches Chat's UX).
  - Forwards files into sessionsStore.sendMessage's opts.

Verified end-to-end on a fresh session: uploaded a 66-byte text file
with a unique sentinel embedded; agent read the file out of the
workspace and recalled the sentinel verbatim. No fallback fired.

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

* fix(session-tab): drop watermark, store last-cache reading directly

agent_sessions.total_context_used was a high-water mark (MAX of all
per-turn cache readings, capped at total_context_max). Claude Code's
auto-compact (~85% of the model window) silently resets the cache
mid-turn, so the watermark asymptoted near the compact threshold and
stopped conveying useful information — every heavy session ended up
visually stuck around 67-69% regardless of actual recent activity.

Switch to direct assignment of the most recent assistant turn's cache
size. The total_context_max cap (cc5c37b) stays as defence-in-depth
against agent-server accounting bugs reporting impossible token
counts. Existing inflated rows on agent-testfix self-heal on next
turn — no migration, no backfill.

Two new unit tests pin the contract: a lower new value overwrites a
higher prior value, and a None reading preserves the prior value
(stdout-pipe-race recovered turns).

* fix(session-tab): rename "% context" to "% last cache" and remove pressure banners

The 75/90/100% banners never fired empirically — Claude Code auto-
compacts mid-turn at ~85% of the model window, which prevents the
underlying metric from ever crossing 75%. A pressure warning that
silently never warns is worse than none.

The subtitle label "% context" reads as session memory pressure when
it's actually the most recent assistant turn's cache size. Relabel to
"% last cache" so the meaning matches what the column actually stores
(after the prior commit dropped the watermark semantics).

Reset modal copy: drop the "context-window pressure" framing for the
same reason, replace with "start a clean line of thought."

Removes orphaned contextPct / contextPctBucket / currentSession
script helpers — no remaining users after the banner deletion.

* docs(user-docs): add Session tab user guide

Covers what the Session tab is, when to use it vs Chat, and the three
limits/behaviours users will hit on a long heavy session:

- The "last cache" metric — explicitly per-turn cache size, NOT
  session memory pressure. Bounces because of auto-compact.
- Auto-compact at ~85% of the model window — what users see (sudden
  drop in % last cache, ~2 min added latency), what survives (working
  memory in compressed form), what doesn't (verbatim history).
- The 50-turn agentic-loop cap (max_turns_task) — per-turn iteration
  budget, NOT session message count. Heavy 12-step tasks routinely
  hit it. Includes a curl recipe for raising the cap per agent via
  PUT /api/agents/{name}/guardrails.

Also documents Reset memory, file attachments, the Session API
endpoint surface, and the four known limitations carried over from
docs/memory/feature-flows/session-tab.md (voice not wired, DR
backup forces fresh sessions, suspended-browser phantom errors,
stdout-pipe-race best-effort recovery).

Modeled after docs/user-docs/agents/agent-chat.md for tone and
structure.

* feat(db): persist auto-compact events on session messages and executions

Claude Code's auto-compact (~85% of the model window) silently summarizes
~170k tokens of conversation into a ~10k summary mid-turn, takes ~2 min,
and previously left no trail in our data — users saw only an unexplained
long execution and a sudden % drop on the next turn.

This change adds three additive nullable columns plus the persistence
plumbing across the standard Trinity router → service → db pipeline:

  - agent_session_messages.compact_metadata (TEXT, JSON list of events)
  - agent_sessions.compact_count            (INTEGER, running tally)
  - schedule_executions.compact_metadata    (TEXT, denormalized for Tasks)

The session-level compact_count drives a future inline "consider starting
fresh" hint without scanning per-message rows. The denormalized copy on
schedule_executions lets the Tasks tab render the badge without joining
session messages — Trinity invariant 1 (router → service → db) preserved
on both the Session router and the task_execution_service.

Migration is idempotent via the existing _safe_add_column helper. Empty
columns on existing rows; populates from the next turn forward once the
agent server is rebuilt with the parser branch (separate commit).

One new unit test pins compact_metadata persistence + compact_count
accumulation across turns; the existing test_session_operations DDL
fixture mirrors the new columns.

* feat(agent-server): parse compact_boundary events and emit structured log

Claude Code emits {"type":"system","subtype":"compact_boundary","compactMetadata":{trigger,preTokens,postTokens,durationMs}}
on its stream-json output when it auto-compacts mid-turn. Both parsers
(parse_stream_json_output for batch, process_stream_line for live) now
recognise the event, append a CompactEvent to metadata.compact_events,
and emit a structured INFO log line per event:

  event=session_auto_compact claude_session_id=... trigger=auto
  pre_tokens=170325 post_tokens=12691 duration_ms=110361

Vector picks the line up via Docker stdout — no infrastructure change.
The compact_events list rides through ExecutionMetadata.model_dump()
into the agent server HTTP response, where the backend extracts and
persists it (separate commit).

Five new unit tests exercise: single-event capture in batch parser,
multi-event ordering within one turn, empty-events on a normal turn,
streaming-parser capture, and model_dump round-trip for the wire shape.

Live behaviour requires rebuilding trinity-agent-base:latest and
recreating each agent container — already done locally at the new SHA.

* feat(session-tab): surface auto-compact events in Session + Tasks UI

Two surfaces, one signal:

TasksPanel — adds a small violet "compacted" badge next to the trigger
badge for any execution that fired one or more compact_boundary events.
Multi-compact turns show `compacted ×N`. Hover tooltip lists each event
with pre→post tokens and duration so latency anomalies become legible
at a glance. Reads task.compact_metadata (denormalized JSON column)
without needing to follow the relation back to agent_session_messages.

SessionPanel — adds an inline italic hint adjacent to the Reset memory
button when the active session has compacted more than 5 times. Reads
session.compact_count (running tally maintained by the DB layer). Quiet
under normal use; visible only when stacked compacts have meaningfully
degraded summary fidelity. Threshold lives as a script-local constant
(COMPACT_HINT_THRESHOLD) for easy tuning from telemetry later.

The currentSession computed (deleted in Bundle A when the pressure
banners went away) is reinstated here since the inline hint needs it.

Pinia store sessions.js needs no change — compact_count and
compact_metadata ride through on the existing API responses without
new state slots.

* fix(db): propagate compact_metadata through DatabaseManager facade

b12307c extended ScheduleOperations.update_execution_status with the
new compact_metadata kwarg but missed the thin facade wrapper in
database.py. The session turn endpoint hit:

  TypeError: DatabaseManager.update_execution_status() got an
  unexpected keyword argument 'compact_metadata'

Trivial fix — the facade just forwards. Caught at runtime on the first
Session turn after Bundle B landed.

* fix(db): propagate compact_metadata through DatabaseManager.add_session_message

Same facade gap as the prior fix for update_execution_status — the thin
wrapper in database.py was missed. Surfaced as:

  TypeError: DatabaseManager.add_session_message() got an unexpected
  keyword argument 'compact_metadata'

…on the first Session-tab turn after the previous facade fix unblocked
the schedule_executions write path.

Adds the same two pass-through kwargs (compact_metadata,
compact_event_count) so the Session router can persist auto-compact
events on session messages and bump the running compact_count tally.

* fix(agent-server): recover from stdout pipe race via JSONL fallback

When a tool subprocess (or MCP grandchild) inherits Claude Code's
stdout fd and wedges the agent server's reader thread, the stream-json
result event is lost. The Phase 5.1 soft-recovery (response_parts !=
[] → synthesize success) only fires when stdout managed to deliver at
least one assistant text block before the wedge. For races that fire
mid-tool-call (zero text emitted to stdout), response_parts is empty
and the soft-recovery falls through to a hard 502.

Symptom users saw: heavy multi-step tasks (e.g. /session-context-pressure
running `python3 -c "..."` via Bash to generate 32KB of synthetic text)
sometimes returned "Execution completed without a result message after
0 tool calls / 1 turns (raw_messages=4)" — even though the JSONL on
disk contained the fully-completed turn. Probabilistic; retry usually
worked; root cause was the kernel pipe buffer exhausting before the
reader thread could drain after grandchildren died.

Fix: when the existing soft-recovery path falls through (no
response_parts text), read the session's JSONL via a side-channel that
is INDEPENDENT of stdout (Claude Code's own session record). Walk
backward to the most recent user-input boundary (string content,
distinguished from tool_results which have list-of-dicts content),
collect every assistant.text block emitted after, and synthesize a
soft-success response. Sets metadata.recovered_from_jsonl=True for
observability. If no text was emitted between boundary and EOF, the
turn is genuinely incomplete and the original 502 surfaces.

11 unit tests cover happy path (matches the actual failure shape with
2× Bash tool_use + tool_result + final text), boundary discrimination
(tool_result must not terminate the user-input search), multi-turn
isolation (only the LAST turn's text recovers), genuine-incomplete
detection (thinking-only or tool-only turns return None and surface
the 502), and robustness (malformed/truncated tail lines, blank lines).

Bounded read at 10MB to defend against pathological JSONL sizes; uses
existing /home/developer/.claude/projects/-home-developer/ path
already known to the cleanup service.

Verified end-to-end on the rebuilt agent-base: 7 turns of
/session-context-pressure executed cleanly with 3 reader-thread races
that drained naturally (none required the new fallback). Recovery is
a safety net only; happy-path behavior unchanged.

Backend reload not required. Agent-base rebuild + container recreate
required to deploy. Backward compat: old images returning no
recovered_from_jsonl field default to False.

* fix(agent-server): pull compact event detail from JSONL after the turn

Bundle B's stdout-side parser detected compact_boundary events but
captured them with all-None pre/post/duration/trigger fields — Claude
Code's --output-format stream-json strips the compactMetadata envelope
on the way out (the JSONL on disk has it, stdout doesn't). Confirmed
on the live agent: stored compact_metadata blobs were
[{"trigger":null,"pre_tokens":null,"post_tokens":null,...}] while the
JSONL contained the canonical shape with real numbers.

Add _extract_compact_events_from_jsonl(session_id, since_iso=None) — a
short helper modeled after the recovery extractor — that scans the
session JSONL post-turn and returns CompactEvent records with the
real detail fields. Called in execute_headless_task right before
returning, scoped via since_iso to records emitted from a turn-start
anchor onward (the JSONL accumulates across the resumed session, so
unfiltered would include compacts from prior turns).

Detection-only stdout branch is retained as a safety net (count
preserved if JSONL read fails) but its log line is removed — the
authoritative log line now fires from the JSONL extract with full
fields.

Effect on existing data: stored compact_metadata blobs from before
this commit keep their nulls (no backfill); new turns from now on
land with full pre_tokens / post_tokens / duration_ms / trigger /
timestamp. Tasks-tab tooltip on the violet "compacted" badge now
shows real numbers.

9 new unit tests (20 total in the suite): canonical JSONL shape,
since_iso scoping with exclusive and inclusive boundaries, multi-
compact ordering, missing/null compactMetadata defensive handling,
malformed-line robustness, empty-result paths.

Live smoke verified: rebuilt base image, recreated testfix, turn
endpoint returns compact_events:[] for non-compacting turns; the
existing session a2trktlTnXA9KD- already shows compact_count=1
unchanged.

Requires agent-base rebuild + container recreate to deploy.

* fix(credentials): strip auto-injected mcpServers.trinity before validating

Commit b474520 (sec(mcp): structure-validate .mcp.json content) added
RESERVED_SERVER_NAMES = {"trinity"} as a defense against attacker-
controlled redefinition of the auto-injected Trinity MCP server entry.
But the agent server's inject_trinity_mcp_if_configured() writes
mcpServers.trinity into .mcp.json on every agent start where
TRINITY_MCP_API_KEY is set — so the file the user loads in the
credentials editor always contains it, and every save trips the
validator with "MCP server name 'trinity' is reserved by Trinity".

Effect on the user: the credentials Save button silently failed for
every agent that had been started at least once. Confirmed regression
introduced by b474520 — pre-this-branch, .mcp.json went through the
inject endpoint with no content validation and the same auto-injected
trinity entry was accepted.

Fix at the backend: strip mcpServers.trinity from the submitted JSON
before it reaches validate_mcp_config. The agent re-injects the
canonical trinity entry from env vars on next startup, so the user
can't lose it by leaving it out of the saved file. The defense-in-
depth value of the reserved-name rule is preserved — an attacker still
can't substitute a different shape under the trinity name, because
their substitution is dropped before validation rather than rejected.

Side benefit: this self-heals corrupted bearer values in the existing
trinity entry (e.g. the historical "Bearer sqlite3.IntegrityError: ..."
strings written by some long-removed prior code path). The agent
overwrites the entry on next start with a fresh value from env.

11 unit tests cover happy path, mixed user+trinity entries, the exact
historical corruption shape, no-op cases (no trinity / no servers /
no mcpServers), robustness on malformed input (passes through to the
validator's own JSON error), and output-shape preservation.

Verified end-to-end on testfix: POST /api/agents/testfix/credentials/inject
with the corrupted-bearer trinity entry + a context7 entry returns
HTTP 200, backend logs the strip, on-disk .mcp.json contains only
context7 with the corrupted bearer cleanly removed.

Backend reload only — no agent rebuild required.

* fix(credentials): allow canonical trinity entry through validator instead of stripping

Replaces the strip approach from 705de47 (Option B) with a shape-
equivalence check in the validator (Option A) — the strip silently
dropped the user's edits to the trinity entry, which broke the
legitimate "rotate my MCP API key" flow on agents that don't have the
auto-inject env var set to recover the entry on next start.

Symptom 705de47 introduced: opening .mcp.json, editing one digit in
the trinity Bearer token, clicking Save → backend stripped the entry
before validation → on-disk file became {"mcpServers": {}} → user's
trinity MCP entry was lost.

Fix: keep RESERVED_SERVER_NAMES = {"trinity"} as the closed-shape
defense, but special-case it in `_validate_entry`: if the entry under
the trinity name matches the canonical Trinity-MCP shape exactly
(only `type`, `url`, `headers` keys; `type=http`; `url` matches the
configured TRINITY_MCP_URL or the documented default; `headers`
contains only `Authorization` with a `Bearer trinity_mcp_…` token),
accept it. Otherwise hit the existing reserved-name reject.

Strict allowlist on the canonical shape:
  - exact key set: {type, url, headers} — no extras
  - type == "http"
  - url ∈ {TRINITY_MCP_URL env, "http://mcp-server:8080/mcp"}
  - headers has only Authorization
  - Authorization matches /^Bearer\s+trinity_mcp_[A-Za-z0-9_-]{1,200}$/

Attacker scenarios still rejected:
  - stdio redefinition (npx + args) under trinity → reserved
  - http with evil URL (https://evil.com/mcp) → reserved
  - canonical url + extra header (X-Custom) → reserved
  - canonical url + non-Bearer auth → reserved
  - canonical url + Bearer with non-trinity_mcp_ token → reserved

Strip helper and its caller in routers/credentials.py reverted.
test_strip_reserved_trinity.py removed (its scenarios now covered by
the validator's own tests).

10 new unit tests in test_mcp_validator.py cover the canonical-shape
allowance + each rejection variant. 102 total tests in the validator
suite — all green.

Live verified end-to-end on testfix:
  - POST /credentials/inject with edited bearer → 200, file updated
  - POST /credentials/inject with stdio shape → 400 reserved-name
  - User's original bearer restored after the test

* chore(gitignore): exclude saved-conversations/ and tests/manual/ from future stages

Both directories are local-only working artifacts (conversation
transcripts and the hand-driven testing kit) that should never be
pushed. They were already gitignored implicitly by being untracked,
but a future ``git add .`` could inadvertently stage them. Listing
them in .gitignore makes the exclusion explicit and accident-proof.

* feat(session-tab): flip feature flag default to True for GA (#651)

Phase 5.3 GA. Session tab is now exposed to users on every fresh
install without an admin opt-in step.

Resolution order is unchanged:
  1. system_settings row 'session_tab_enabled' if present (admin override)
  2. SESSION_TAB_ENABLED env var (only honored as opt-out: false/0/no)
  3. Default: True

Admins who want to keep it hidden can set
``session_tab_enabled = false`` in system_settings or export
``SESSION_TAB_ENABLED=false``.

Closes the Phase 5.3 step in #651.

* docs: address PR #652 review feedback — requirements, GA status, security section

- Add §5.8 Session Tab entry to requirements.md (Rule 4 — new P1 capability
  must be registered in the single source of truth)
- Flip three stale "default off / Phase 5 rollout pending" references in
  architecture.md, feature-flows.md, and feature-flows/session-tab.md to
  reflect the GA default-on flag flip from PR #651
- Add ## Security Considerations section to feature-flows/session-tab.md
  covering 404-not-403 ownership isolation (E6), Redis lock for concurrent
  --resume (Anthropic #20992), cross-session contamination empirical gate
  (Anthropic #26964), and JSONL prompt-injection persistence with the
  Reset memory mitigation

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 Jun 1, 2026
* refactor(frontend): sweep auth + channels + top-level views (#554) (#609)

* refactor(frontend): sweep auth + channels + top-level views (#554)

Slice 4 — bundles three subdomains into one PR per request:

  AUTH (3 files, 24 migrations)
    - Login.vue           — error icon
    - SetupPassword.vue   — password match indicator, requirement checklist,
                            error banner, 6-tier strength visualization
                            (red/red/orange/yellow/green/green)
    - MobileAdmin.vue     — logout, status dot, fleet running/high-context
                            counts, chat thinking status

  CHANNELS (5 files, 23 migrations)
    - PublicLinksPanel    — Active badge, expired text, Slack
                            connected/enable/disable/delete icons,
                            form error, delete-confirm modal,
                            success toast
    - WhatsAppChannelPanel — connected dot, sandbox badge, disconnect btn,
                             webhook warning, success/error message
    - TelegramChannelPanel — connected dot, disconnect btn, webhook
                             warning, group-remove, success/error message
    - SlackChannelPanel   — connected dot, disconnect btn, success/error
    - SharingPanel        — Approve button, success/error message,
                            remove button

  TOP-LEVEL VIEWS (4 files, 33 migrations)
    - Dashboard.vue       — running count + dot, message count, clear-tags,
                            connection status dot, history badge,
                            live-feed indicator, message arrow icon
    - PublicChat.vue      — agent online dot, invalid-link icon + bg,
                            agent-unavailable icon + bg, verify error,
                            chat error
    - OperatingRoom.vue   — empty-state success indicator
    - Templates.vue       — error icon

80 token replacements; net diff +80 / -80 (all 1:1 palette aliases).

Deferred (30 raw refs remain; all need new token families):
  - Login: 8 blue (primary action buttons + focus rings)
  - Dashboard: 10 (blue actions, purple tag-cloud button, blue selected-tab)
  - OperatingRoom: 5 (blue selected-tab indicators)
  - PublicChat: 5 (amber AUTO badge, rose READ-ONLY badge, indigo loading)
  - WhatsApp + Sharing: 2 (amber "deployment prerequisite" notices)

These map to pending `action-primary`, `state-selected`, and an accent
expansion that's tracked under #555 follow-up territory.

Tests:
  No new specs — these routes are either auth pages (covered by
  auth.setup), already smoke-tested (Templates), or require fixtures
  (PublicChat needs a public link token; MobileAdmin lives at /m).
  Existing 6 @smoke tests cover the high-traffic routes.

Refs #554

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

* fix(vite): tighten /api proxy prefix to /api/

`/api` (no trailing slash) is a path-prefix match in http-proxy-middleware,
so the SPA route `/api-keys` was being captured by the proxy and forwarded
to the backend, returning 404 in dev mode.

This silently broke the `/api-keys` @smoke e2e test on every PR since the
test was added in #597 — that PR's frontend-e2e check failed on merge but
wasn't required, so the failure was missed.

Backend endpoints all live under `/api/...` (with slash), so the tighter
prefix preserves all real proxy traffic and only excludes the SPA route.

Verified locally: 7/7 @smoke tests pass after this change (was 6/7 with
/api-keys failing).

Refs #554 #556

---------

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

* refactor(frontend): sweep cross-cutting + chat + file-mgr + process + misc (#554) (#623)

* refactor(frontend): sweep auth + channels + top-level views (#554)

Slice 4 — bundles three subdomains into one PR per request:

  AUTH (3 files, 24 migrations)
    - Login.vue           — error icon
    - SetupPassword.vue   — password match indicator, requirement checklist,
                            error banner, 6-tier strength visualization
                            (red/red/orange/yellow/green/green)
    - MobileAdmin.vue     — logout, status dot, fleet running/high-context
                            counts, chat thinking status

  CHANNELS (5 files, 23 migrations)
    - PublicLinksPanel    — Active badge, expired text, Slack
                            connected/enable/disable/delete icons,
                            form error, delete-confirm modal,
                            success toast
    - WhatsAppChannelPanel — connected dot, sandbox badge, disconnect btn,
                             webhook warning, success/error message
    - TelegramChannelPanel — connected dot, disconnect btn, webhook
                             warning, group-remove, success/error message
    - SlackChannelPanel   — connected dot, disconnect btn, success/error
    - SharingPanel        — Approve button, success/error message,
                            remove button

  TOP-LEVEL VIEWS (4 files, 33 migrations)
    - Dashboard.vue       — running count + dot, message count, clear-tags,
                            connection status dot, history badge,
                            live-feed indicator, message arrow icon
    - PublicChat.vue      — agent online dot, invalid-link icon + bg,
                            agent-unavailable icon + bg, verify error,
                            chat error
    - OperatingRoom.vue   — empty-state success indicator
    - Templates.vue       — error icon

80 token replacements; net diff +80 / -80 (all 1:1 palette aliases).

Deferred (30 raw refs remain; all need new token families):
  - Login: 8 blue (primary action buttons + focus rings)
  - Dashboard: 10 (blue actions, purple tag-cloud button, blue selected-tab)
  - OperatingRoom: 5 (blue selected-tab indicators)
  - PublicChat: 5 (amber AUTO badge, rose READ-ONLY badge, indigo loading)
  - WhatsApp + Sharing: 2 (amber "deployment prerequisite" notices)

These map to pending `action-primary`, `state-selected`, and an accent
expansion that's tracked under #555 follow-up territory.

Tests:
  No new specs — these routes are either auth pages (covered by
  auth.setup), already smoke-tested (Templates), or require fixtures
  (PublicChat needs a public link token; MobileAdmin lives at /m).
  Existing 6 @smoke tests cover the high-traffic routes.

Refs #554

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

* fix(vite): tighten /api proxy prefix to /api/

`/api` (no trailing slash) is a path-prefix match in http-proxy-middleware,
so the SPA route `/api-keys` was being captured by the proxy and forwarded
to the backend, returning 404 in dev mode.

This silently broke the `/api-keys` @smoke e2e test on every PR since the
test was added in #597 — that PR's frontend-e2e check failed on merge but
wasn't required, so the failure was missed.

Backend endpoints all live under `/api/...` (with slash), so the tighter
prefix preserves all real proxy traffic and only excludes the SPA route.

Verified locally: 7/7 @smoke tests pass after this change (was 6/7 with
/api-keys failing).

Refs #554 #556

* refactor(frontend): sweep cross-cutting + chat + file-mgr + process + misc (#554)

Slice 7 stacked on #609. Migrates 24 files spanning the small-domain pool:

  CHAT (4 files)
    ChatBubble  — copy-success checkmark, self-task accent panel (purple)
    ChatPanel   — agent-not-running warning state, error banner
    ChatInput   — file-remove button, voice-active recording indicator
    ChatHistoryDropdown — error state

  FILE MANAGER (4 files)
    FileManager        — notification toast (success/error), no-agents warning,
                         loading error, delete button + modal + confirm action
    FileTreeNode       — search-matched row highlight (file-type icons stay raw
                         decorative; need their own accent palette later)
    FilePreview        — preview-error icon + text
    FileSharingPanel   — Revoke button

  PROCESS (3 files)
    TrendChart        — completed/failed/cost bars (chart series + legend),
                         success-rate threshold ladder
    RoleMatrix        — no-executor row + badge
    TemplateSelector  — category badges (business/devops/support → status-info /
                         accent-purple / status-urgent)

  CROSS-CUTTING / MODALS (10 files)
    NavBar               — Ops critical-pulse + high indicator, WS connected dot
    GitConflictModal     — yellow warning header (×2), all destructive (red) options
    ReplayTimeline       — system-agent purple panel + badge, schedule-marker arrow,
                            live-feed dot, activity-state success rate ladder
    UnifiedActivityPanel — running/success/fail indicators (live + modal)
    OnboardingChecklist  — completed-state styling (ring, bg, indicator, text)
    CreateAgentModal     — templates-error + general error
    ConfirmDialog        — danger/warning variant icons, text, confirm buttons
    ResourceModal        — (no migrations — amber notice, deferred)
    AvatarGenerateModal  — error text, remove-avatar button
    HelpChatWidget       — error banner + retry button

  MISC (3 files)
    YamlEditor          — error and warning banners + counts + success checkmark
    EditorHelpPanel     — required-field indicator
    TerminalPanelContent — restart-required notice, start-agent button
    TagsEditor          — error message

Net diff: 24 files, +106 / -106 (1:1 palette aliases, byte-identical CSS).

Deferred (existing pattern):
  - Indigo / blue primary action buttons (Login & elsewhere)
  - Blue selected-state (NavBar tabs, OperatingRoom tabs)
  - Amber notices (ResourceModal, RoleMatrix amber missing-role marker —
    these still use the amber palette which differs from yellow)
  - File-type icon colors in FileTreeNode (decorative, need accent-yellow /
    accent-purple-blue / etc. — folder ≠ warning, video ≠ accent, etc.)
  - Slack/Telegram/WhatsApp logo brand colors

These map to the pending `action-primary`, `state-selected`, and accent-color-
expansion tickets.

Verified locally:
  - npm run check:tokens                         passes (10 tokens valid)
  - npm run build                                passes
  - npm run test:e2e:smoke (7 tests, 7.9s)       all green

Refs #554

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

---------

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

* refactor(frontend): sweep agent surfaces — 22 files (#554) (#614)

* refactor(frontend): sweep auth + channels + top-level views (#554)

Slice 4 — bundles three subdomains into one PR per request:

  AUTH (3 files, 24 migrations)
    - Login.vue           — error icon
    - SetupPassword.vue   — password match indicator, requirement checklist,
                            error banner, 6-tier strength visualization
                            (red/red/orange/yellow/green/green)
    - MobileAdmin.vue     — logout, status dot, fleet running/high-context
                            counts, chat thinking status

  CHANNELS (5 files, 23 migrations)
    - PublicLinksPanel    — Active badge, expired text, Slack
                            connected/enable/disable/delete icons,
                            form error, delete-confirm modal,
                            success toast
    - WhatsAppChannelPanel — connected dot, sandbox badge, disconnect btn,
                             webhook warning, success/error message
    - TelegramChannelPanel — connected dot, disconnect btn, webhook
                             warning, group-remove, success/error message
    - SlackChannelPanel   — connected dot, disconnect btn, success/error
    - SharingPanel        — Approve button, success/error message,
                            remove button

  TOP-LEVEL VIEWS (4 files, 33 migrations)
    - Dashboard.vue       — running count + dot, message count, clear-tags,
                            connection status dot, history badge,
                            live-feed indicator, message arrow icon
    - PublicChat.vue      — agent online dot, invalid-link icon + bg,
                            agent-unavailable icon + bg, verify error,
                            chat error
    - OperatingRoom.vue   — empty-state success indicator
    - Templates.vue       — error icon

80 token replacements; net diff +80 / -80 (all 1:1 palette aliases).

Deferred (30 raw refs remain; all need new token families):
  - Login: 8 blue (primary action buttons + focus rings)
  - Dashboard: 10 (blue actions, purple tag-cloud button, blue selected-tab)
  - OperatingRoom: 5 (blue selected-tab indicators)
  - PublicChat: 5 (amber AUTO badge, rose READ-ONLY badge, indigo loading)
  - WhatsApp + Sharing: 2 (amber "deployment prerequisite" notices)

These map to pending `action-primary`, `state-selected`, and an accent
expansion that's tracked under #555 follow-up territory.

Tests:
  No new specs — these routes are either auth pages (covered by
  auth.setup), already smoke-tested (Templates), or require fixtures
  (PublicChat needs a public link token; MobileAdmin lives at /m).
  Existing 6 @smoke tests cover the high-traffic routes.

Refs #554

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

* fix(vite): tighten /api proxy prefix to /api/

`/api` (no trailing slash) is a path-prefix match in http-proxy-middleware,
so the SPA route `/api-keys` was being captured by the proxy and forwarded
to the backend, returning 404 in dev mode.

This silently broke the `/api-keys` @smoke e2e test on every PR since the
test was added in #597 — that PR's frontend-e2e check failed on merge but
wasn't required, so the failure was missed.

Backend endpoints all live under `/api/...` (with slash), so the tighter
prefix preserves all real proxy traffic and only excludes the SPA route.

Verified locally: 7/7 @smoke tests pass after this change (was 6/7 with
/api-keys failing).

Refs #554 #556

* refactor(frontend): sweep cross-cutting + chat + file-mgr + process + misc (#554)

Slice 7 stacked on #609. Migrates 24 files spanning the small-domain pool:

  CHAT (4 files)
    ChatBubble  — copy-success checkmark, self-task accent panel (purple)
    ChatPanel   — agent-not-running warning state, error banner
    ChatInput   — file-remove button, voice-active recording indicator
    ChatHistoryDropdown — error state

  FILE MANAGER (4 files)
    FileManager        — notification toast (success/error), no-agents warning,
                         loading error, delete button + modal + confirm action
    FileTreeNode       — search-matched row highlight (file-type icons stay raw
                         decorative; need their own accent palette later)
    FilePreview        — preview-error icon + text
    FileSharingPanel   — Revoke button

  PROCESS (3 files)
    TrendChart        — completed/failed/cost bars (chart series + legend),
                         success-rate threshold ladder
    RoleMatrix        — no-executor row + badge
    TemplateSelector  — category badges (business/devops/support → status-info /
                         accent-purple / status-urgent)

  CROSS-CUTTING / MODALS (10 files)
    NavBar               — Ops critical-pulse + high indicator, WS connected dot
    GitConflictModal     — yellow warning header (×2), all destructive (red) options
    ReplayTimeline       — system-agent purple panel + badge, schedule-marker arrow,
                            live-feed dot, activity-state success rate ladder
    UnifiedActivityPanel — running/success/fail indicators (live + modal)
    OnboardingChecklist  — completed-state styling (ring, bg, indicator, text)
    CreateAgentModal     — templates-error + general error
    ConfirmDialog        — danger/warning variant icons, text, confirm buttons
    ResourceModal        — (no migrations — amber notice, deferred)
    AvatarGenerateModal  — error text, remove-avatar button
    HelpChatWidget       — error banner + retry button

  MISC (3 files)
    YamlEditor          — error and warning banners + counts + success checkmark
    EditorHelpPanel     — required-field indicator
    TerminalPanelContent — restart-required notice, start-agent button
    TagsEditor          — error message

Net diff: 24 files, +106 / -106 (1:1 palette aliases, byte-identical CSS).

Deferred (existing pattern):
  - Indigo / blue primary action buttons (Login & elsewhere)
  - Blue selected-state (NavBar tabs, OperatingRoom tabs)
  - Amber notices (ResourceModal, RoleMatrix amber missing-role marker —
    these still use the amber palette which differs from yellow)
  - File-type icon colors in FileTreeNode (decorative, need accent-yellow /
    accent-purple-blue / etc. — folder ≠ warning, video ≠ accent, etc.)
  - Slack/Telegram/WhatsApp logo brand colors

These map to the pending `action-primary`, `state-selected`, and accent-color-
expansion tickets.

Verified locally:
  - npm run check:tokens                         passes (10 tokens valid)
  - npm run build                                passes
  - npm run test:e2e:smoke (7 tests, 7.9s)       all green

Refs #554

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

* refactor(frontend): sweep agent surfaces — 22 files (#554)

Slice 8 of the design-system migration. Replaces semantic palette refs
with status/state/accent tokens across all per-agent panels, the
agents list, and agent-detail surfaces.

Files migrated:
- 16 panels (Tasks, Git, Dashboard, Playbooks, Nevermined, Info,
  Credentials, Schedules, SystemViewEditor, HostTelemetry, Folders,
  Files, Skills, Observability, Permissions, Metrics)
- 5 agent surfaces (Agents, AgentNode, AgentHeader, AgentTerminal,
  AgentDetail)
- SystemAgentNode

Mappings (palette-equivalent, no visual change):
- yellow → status-warning
- green  → status-success
- red    → status-danger
- orange → status-urgent
- amber  → state-autonomous (token name slightly stretched for tool-call
  / queued-task amber; same palette, future cleanup may rename)
- rose   → state-locked
- purple → accent-purple

Deferred (no token family yet):
- indigo (action-primary)
- blue/sky/cyan (selected-state, category labels)
- teal (category labels)

SystemViewsSidebar untouched — only contains deferred blue/indigo
selected-state references.

Verification:
- npm run check:tokens → 10 tokens equivalent, all references resolve
- npm run build → clean
- npm run test:e2e:smoke → 7/7 passed against live Trinity (HMR)

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-runtime): kill npx MCP orphans outside claude pgid that hold stdout pipe open (#618) (#620)

* fix(voice): orb animation loop dies before voice session starts

The canvas is inside v-if="voice.isActive.value" so canvasEl.value is
null when onMounted fires. renderFrame() exits early without scheduling
the next frame, killing the loop permanently.

Replace onMounted initialization with watch(canvasEl) so the RAF loop
starts when the canvas enters the DOM and stops when it leaves.

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

* fix(agent-runtime): kill npx MCP orphans outside claude pgid that hold stdout pipe open (#618)

After terminate_process_group kills claude's pgid, npm→node MCP server chains
spawned via npx call setsid() and land in a new session — they survive the
pgid kill and keep the stdout pipe write FD open indefinitely.  The kernel
cannot deliver EOF to our reader thread while any writer FD remains open, so
drain_reader_threads blocked for the full 30s post_kill_grace, then lost the
buffered result line via force-close (HTTP 502).

Add _kill_orphan_pipe_writers(): after terminate_process_group, scan /proc/*/fd
for any process outside our pgid that holds the pipe's write end (detected via
fdinfo flags), and SIGKILL it.  Killing the orphan releases all its FDs
(stdout AND stderr write ends) simultaneously, delivering EOF to both reader
threads so they drain naturally before the post_kill_grace window.

New tests (Linux-only, skipped on macOS — /proc required): verify that a
setsid() grandchild is detected and killed, that our own read-end process is
not touched, and that the end-to-end drain path preserves buffered data.

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

---------

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

* fix(slack): replace slackify-markdown with own renderer (#293) (#622)

Replaces slackify-markdown with a custom renderer fixing 5 compounding bugs (nested lists, headings, blockquotes, tables, horizontal rules). Includes 35 unit tests and updated feature flow doc.

Closes #293

Co-Authored-By: pavshulin <pavshulin@users.noreply.github.com>

* feat(agents): per-agent token usage display in AgentHeader (#250) (#632)

Adds a token usage row to AgentHeader showing 7-day cost sparkline,
today's cost vs 7-day daily average (with trend arrow), and lifetime
totals. Data sourced from schedule_executions in the DB so it persists
across agent restarts.

- New GET /api/agents/{name}/token-stats endpoint
- ScheduleOperations.get_agent_token_stats(): single-pass 24h/7d/lifetime
  aggregation + 7-day daily breakdown with gap-filling
- agentsStore.getAgentTokenStats() action
- TOKEN USAGE ROW in AgentHeader.vue: SparklineChart (amber, 56x16),
  trend indicator (warning/success/gray), lifetime summary
- Hidden for agents with no runs (lifetime_executions == 0)

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

* feat(site): agent website proxy via /site/{token} endpoint (SITE-001) (#634)

Adds live HTTP reverse-proxy so agents can serve public websites from
their container. A new `type='site'` public link routes requests through
`GET /site/{token}/{path}` → httpx streaming proxy → agent web server at
`http://agent-{name}:3000`. Includes DB migration, nginx routing, rate
limiting (per-IP + per-token), SSRF guard, security header stripping, and
audit event `site_link_visit`. UI adds Chat/Website selector in the link
create modal with a "Website" badge on site links.

Fixes #633

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

* fix(site): centralize SITE_PORT, atomic rate limit, fire-and-forget audit log, update docs (SITE-001)

- Move SITE_PORT to config.py; import in site.py and public_links.py
- Fix TOCTOU race in _check_site_rate_limit: pipeline INCR+check-after
- Audit log is now asyncio.create_task() so streaming is not delayed
- Add SITE-001 to requirements.md (section 15.1a-3)
- Add site.py to architecture.md router listing + /site/ endpoint table

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

* fix(agent-runtime): bound _kill_orphan_pipe_writers to 10s to prevent drain stall (#649) (#650)

/proc scanning can block indefinitely when a process is in D state
(uninterruptible sleep), causing drain_reader_threads to stall for
tens of minutes instead of the expected ~30 seconds.

Run _kill_orphan_pipe_writers in a daemon thread with a 10s cap so
a blocked /proc entry cannot push the drain past its deadline.

Also use wall-clock accounting for the post-kill join timeout so time
spent in terminate + orphan scan doesn't silently erode the budget,
and log actual elapsed time instead of the expected value so future
incidents are easier to diagnose.

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

* fix(security): voice WebSocket + stop endpoint missing ownership check (#600) (#638)

The /ws/voice/{voice_session_id} handler decoded the JWT but threw the
payload away — only the signature was checked. Any authenticated user
holding a valid JWT who learned the 128-bit session id (logs, browser
inspection, XSS) could attach to the audio stream, eavesdrop on the
victim's transcript, and trigger tool calls audit-logged under the
victim's identity.

POST /api/agents/{name}/voice/stop had the same gap: the path agent was
gated via get_authorized_agent, but request.voice_session_id was never
cross-checked against the path agent or the caller's user_id, so the
caller could end and persist a transcript onto another user's session.

Fix:
- WS: extract sub from the decoded JWT, look up the user, and close 4003
  if user.id != session.user_id (admin role bypasses, for support).
- voice_stop: load the session via get_session before mutating, assert
  agent_name == path name AND user_id == current_user.id (admin bypasses),
  raise 403 otherwise.

Added tests/unit/test_voice_auth.py covering: missing token, invalid
token, missing sub claim, unknown user, owner happy path, admin bypass,
attacker rejected, plus voice_stop variants. Loads voice.py via
importlib to avoid pulling in the full routers/__init__.py chain.

Reported by /security-review on PR #599 (2026-04-30); origin commit
7d8abe8 (#581 voice tool calls).

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

* fix(auth): split login rate limit into per-account + per-IP buckets (#591) (#621)

Pentest finding AISEC-H2 (CVSS 7.5, CWE-307): the previous design used a
single per-IP bucket at 5 fails / 10 min. Any user behind a corporate
NAT, VPN, or CDN locked out everyone else at the same egress IP after
just four bad attempts. A rotating-proxy attacker could keep an
organisation locked out continuously, so the protection doubled as a
platform-wide DoS primitive.

Replace with two independent buckets:

  * Per-account (tight) — 5 fails / 15 min: limits credential stuffing
    on one targeted account; never affects other accounts.
  * Per-IP (loose)      — 30 fails / 5 min: catches single-source abuse
    but stays well above the legitimate-traffic threshold for users
    sharing a NAT/VPN/CDN egress.

Both buckets are checked on every attempt; 429 fires when either is
exhausted. Successful login clears both. Account names are normalised
(lowercase + strip) before keying. Endpoints without an account context
(public access-request) skip the per-account bucket and rely on the
per-IP one only.

Lockout state-changes log a structured WARNING (visible via Vector) so
operators can see when buckets are being exercised.

Live verification on the running backend:
  attempts 1-5 → 401 (counter ticking)
  attempt  6   → 429 "Too many failed attempts for this account..."
  valid pwd    → 429 (account stays locked even with right password)
  other account from same IP → 401 (per-account isolation works)

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

* fix(security): require creator role on /api/systems/deploy (#592) (#624)

Auditing all entry points that ultimately call `create_agent_internal`
(per #592 AC #2) turned up a real bypass on `POST /api/systems/deploy`.
The system-manifest deployment route gated on `Depends(get_current_user)`
without a role check, so any authenticated user-role account could spawn
an entire fleet of agents through that path — a strictly stronger
privilege than the single-agent bypass the AISEC-H1 finding originally
named (which `POST /api/agents/deploy-local` had already closed via #150).

Add `Depends(require_role("creator"))` to `deploy_system`, matching the
existing dependencies on `POST /api/agents` and `POST /api/agents/deploy-local`.

Regression test (`tests/unit/test_agent_creation_role_gates.py`) walks
the FastAPI router source AST and asserts that every agent-creation
route uses `Depends(require_role("creator"))`. AST-level so the check is
fast, stable across formatting changes, and fires the moment someone
removes the dependency. Confirmed the test catches the regression by
reverting the change and observing the test fail before re-applying.

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

* fix(security): stop mirroring JWT into document.cookie (#188) (#642)

* fix(security): stop mirroring JWT into document.cookie (#188)

UnderDefense pentest 3.3.5 flagged the frontend for mirroring the
authentication token into a `token` cookie without `Secure` or
`HttpOnly` flags. The cookie was set in setupAxiosAuth via
`document.cookie =` so it was readable from JS (HttpOnly is impossible
on JS-set cookies), transmitted over HTTP without the Secure flag, and
auto-attached to every outbound request as a CSRF vector.

The cookie's stated purpose was "for nginx auth_request to validate
agent UI access" — but that nginx directive was never configured in
any committed deployment (`grep -r auth_request -- *.conf` is empty,
git log -S confirms it never existed). The cookie was pure attack
surface with zero functional value.

Per the issue's "Best" remediation, drop the cookie mirror entirely.
API authentication uses the `Authorization: Bearer` header
exclusively; nothing else needs the cookie.

The cookie-clear on logout is intentionally kept so users carrying a
stale cookie from the pre-fix version get cleaned up on their next
logout cycle. The cookie's `max-age=1800` also naturally expires it
within 30 minutes of the upgrade.

The backend's `/api/auth/validate` endpoint still accepts a cookie as
one of three token sources — left untouched as out-of-scope. With the
frontend no longer setting the cookie nothing legitimate sends one,
but the fallback path remains available if a future nginx
auth_request setup is wired up properly (with Secure + HttpOnly flags
set server-side via Set-Cookie, not via document.cookie).

Closes #188.

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

* docs(admin-login): remove stale references to JWT cookie mirror (#188)

PR #642 removed the document.cookie set in setupAxiosAuth, but the
admin-login feature flow still documented the cookie as live. Update
the code snippet and the storage table to reflect current behaviour.

Note in the snippet describes why the cookie was removed so readers
who see the diff history can find the rationale without reading the
PR.

---------

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

* fix(security): override git User-Agent on skills library sync (#184) (#646)

UnderDefense pentest 3.3.1 flagged the backend for leaking the
underlying tech stack via outbound User-Agent. Skills sync uses git
subprocess (not httpx), so the leaked UA is `git/<version>
(libcurl/<version> ...)` — verified live with GIT_TRACE_CURL=1.

Add `-c http.useragent=Trinity-Skills-Sync` (positioned correctly
before the subcommand, as git's `-c` requires) to the two HTTP-bearing
git invocations: `_git_clone` and `_git_pull`'s fetch. The local-only
`git reset --hard` and `git rev-parse HEAD` calls intentionally do
not get the flag — they make no HTTP and threading the flag through
would suggest otherwise.

The SSRF allowlist (#179) already locks the destination to github.com
so the practical exposure is small (GitHub already knows what we are),
but defense-in-depth: even if the allowlist is ever loosened the UA
stays generic.

The constant has no version suffix to avoid yet another version string
drifting against VERSION / package.json / pyproject.

Tests in tests/unit/test_skill_service_user_agent.py mock subprocess
and assert the flag is present at the right argv position for clone
and fetch, and absent for the local-only reset and rev-parse calls.

Live verification with `GIT_TRACE_CURL=1 git -c http.useragent=... ls-remote ...`
confirms the wire UA changes from `git/2.43.0` to `Trinity-Skills-Sync`.

Closes #184.

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

* docs(deploy): document stale-image symptom + recovery in start.sh and DEPLOYMENT.md (#557) (#626)

Self-hosted developers tracking `dev` occasionally pull a commit that
adds a new Python or Node dependency to one of the platform Dockerfiles,
re-run `start.sh`, and end up with new source running against an old
image's Python env. Uvicorn crashes with `ModuleNotFoundError`, compose
keeps respawning the worker, and `start.sh` reports success — leaving
the UI "Disconnected" with no obvious diagnosis.

Adopting Option B from #557 discussion (PR #625 closed): treat this as
a documentation problem rather than building auto-detection into the
critical-path startup script. Auto-detection has clear cost (Python
subprocess + Docker inspect on every cold start) and unclear benefit
(production deploys use `compose pull` and don't hit this; the affected
population is self-hosted devs whose recovery is one command).

Two changes:
- `scripts/deploy/start.sh`: append a 4-line hint after the "Ready!"
  banner naming the symptom (`ModuleNotFoundError`, "Disconnected" UI)
  and the exact recovery command.
- `docs/DEPLOYMENT.md`: add a Troubleshooting entry with full diagnosis
  walkthrough, root-cause explanation, and the rationale for not
  auto-detecting (links to #557).

A future `scripts/deploy/upgrade.sh` is the right place to bundle
backup + rebuild + start + verify for the explicit upgrade path; that
is bigger-than-#557 scope.

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

* fix(security): lock down Redis — auth + ACL + network split (#589)

* 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>

* fix(dev): update gitea overlay network name after #589 split

trinity-network no longer exists; gitea dev overlay must attach to
trinity-agent-network (the preserved agent-network name).

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

* fix(tests): use credentialed Redis URL in scheduler test_config fixture (#661)

After #589 hardened Redis auth, SchedulerConfig raises on bare redis:// URLs.
The test_config fixture bypassed the env-level patch in tests/conftest.py by
passing redis_url="redis://localhost:6379" directly.

Fixes #659

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

* Session tab — `--resume`-default chat surface (Closes #651) (#652)

* docs(planning): add Session tab design — --resume-default chat surface

Adds docs/planning/SESSION_TAB_2026-04.md, the comprehensive plan for a
new "Session" tab living alongside Chat. Sessions reattach to their own
Claude Code JSONL via --resume, preserving tool memory, mid-skill state,
and reasoning state across turns.

Plan covers:
- UI design (tab placement, multi-session model, +New Session, Reset memory)
- Data model (agent_sessions / agent_session_messages — parallel to chat)
- Backend architecture (separate router, single shared change to
  task_execution_service for persist_session plumbing)
- Phased rollout (foundation → backend → frontend → hardening → GA)
- Edge cases & failure-mode lessons baked in from a prior local spike
  (parser bug, --no-session-persistence dependency, cold-turn detection,
  port allocation)
- Test plan including the cross-session contamination test for
  Anthropic claude-code#26964
- Retention/cleanup policy, observability, security checklist
- Local-first workflow: implementation runs entirely on this branch
  until validation passes; only then does the standard SDLC engage
  (issue, push, PR)

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

* feat(db): add agent_sessions + agent_session_messages tables

Phase 1.1 of the Session tab plan (docs/planning/SESSION_TAB_2026-04.md).
Schema definitions go in db/schema.py for fresh installs; the matching
idempotent migration agent_sessions_tables in db/migrations.py upgrades
existing databases.

The schema mirrors chat_sessions / chat_messages but is strictly parallel
— no foreign keys, no shared columns, separate index namespace. Three
fields are unique to the session model:

- agent_sessions.cached_claude_session_id — the Claude Code session UUID
  the next turn will pass to ``--resume``
- agent_sessions.consecutive_resume_failures — drives the resume-failure
  fallback (Phase 2.2)
- agent_session_messages.cache_read_tokens — observability for whether
  Anthropic's prompt cache engaged

CASCADE on session delete cleans up message rows automatically.

Verified locally: backend restart applies the migration cleanly, tables
have 15 columns each with correct types/defaults/PKs, all four indexes
created, second restart confirms idempotency.

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

* feat(db): add SessionOperations for Session tab persistence

Phase 1.2 of the Session tab plan (docs/planning/SESSION_TAB_2026-04.md).

- Adds AgentSession and AgentSessionMessage Pydantic models in db_models.py
  with the new fields the Session tab needs beyond ChatSession/ChatMessage:
  cached_claude_session_id, last_resume_at, consecutive_resume_failures on
  the session row, and cache_read_tokens + claude_session_id on each message.

- Creates db/sessions.py with a SessionOperations class mirroring the
  ChatOperations shape: create_session, get_session, list_sessions,
  delete_session, add_session_message, get_session_messages, plus the
  Claude UUID cache helpers (get/update/clear_cached_claude_session_id)
  and resume health helpers (mark_resume_failure, mark_resume_success).

- Wires the new ops into the DatabaseManager facade alongside the
  existing _chat_ops, with one delegating method per public operation.

No router, no agent-server change, no frontend yet — those land in later
phases. Tables agent_sessions and agent_session_messages already exist
from the prior schema commit.

* feat(session-tab): backend foundation for --resume-default Session surface

Phases 1.3 through 1.7 of the Session tab plan
(docs/planning/SESSION_TAB_2026-04.md). Pure backend / agent-server work
behind a flag — no UI surface yet, no behavior change to Chat or any
existing /task caller.

Agent server (base image):

- Stream-json parser fix (Appendix B). Both parse_stream_json_output and
  process_stream_line now recognize {"type":"system","subtype":"init"}
  for session_id capture, with the result event as a fallback when init
  was missed (truncated streams). The legacy bare-init shape is
  intentionally rejected. This is the same bug that would have made
  Session caching corrupt on every cold turn.

- Same bug in execute_headless_task's permission-mode validation site:
  the check matched the wrong shape, so permission_mode_validated never
  flipped to True and the protective kill-on-misconfigured-permission
  path silently failed open. Now uses type=system + subtype=init.

- New persist_session flag threaded through ParallelTaskRequest →
  routers/chat.py → AgentRuntime ABC → ClaudeCodeRuntime.execute_headless
  → execute_headless_task. When True, --no-session-persistence is
  omitted so the JSONL is written and the next turn's --resume can find
  it. --session-id is still passed for unique cold-turn namespace.
  Default False keeps every existing caller stateless.

- gemini_runtime accepts the parameter for ABC parity and ignores it
  (Gemini CLI has no resume).

Backend:

- task_execution_service.execute_task now accepts persist_session: bool
  = False and threads it into the agent payload. All existing callers
  (Chat, schedules, MCP, fan-out, webhooks) keep today's behavior; only
  the future routers/sessions.py (Phase 2) opts in.

- settings_service.is_session_tab_enabled() — feature flag resolving
  system_settings.session_tab_enabled → SESSION_TAB_ENABLED env →
  False. Module-level convenience function exposed.

Tests (run inside trinity-backend container — Python 3.11):

- tests/unit/test_session_operations.py — 9 tests against an isolated
  SQLite DB exercising the full SessionOperations CRUD plus the cached
  claude session UUID lifecycle and resume failure / success counters.

- tests/unit/test_claude_code_session_id_parser.py — 8 tests covering
  both parsers (batch + streaming): system/init recognition, result
  fallback, init-wins-over-result, legacy bare-init rejection, and a
  source-level regression guard for the permission-mode validation
  fix.

- tests/unit/test_session_persistence_flag.py — 8 tests pinning the
  contract: signatures across the runtime ABC, ParallelTaskRequest,
  agent chat router, execute_headless_task, and
  task_execution_service.execute_task. Includes the gating regex check
  on --no-session-persistence and a live signature import to catch
  drift AST parsing alone would miss.

Total: 25 passing tests covering every touchpoint of Phase 1.

Base image (trinity-agent-base) rebuilt to embed the agent-server
changes; existing agent containers will pick them up on next recreate.

* feat(session-tab): backend turn endpoint for --resume-default Session surface

Phase 2 of docs/planning/SESSION_TAB_2026-04.md. Six endpoints under
/api/agents/{name}/session{s,...} that mirror routers/chat.py's auth
model and TaskExecutionService usage but persist to the parallel
agent_sessions / agent_session_messages tables and request
persist_session=True on every turn so each call reattaches via
`claude --print --resume <uuid>`.

Surface gated on is_session_tab_enabled() — flag-off default returns
404 from every endpoint.

  POST   /api/agents/{name}/session                  create row
  GET    /api/agents/{name}/sessions                 list (per-user)
  GET    /api/agents/{name}/sessions/{id}            session + messages
  POST   /api/agents/{name}/sessions/{id}/message    THE turn
  POST   /api/agents/{name}/sessions/{id}/reset      clear cached uuid
  DELETE /api/agents/{name}/sessions/{id}            delete row + msgs

Spike-pitfall defenses baked into the turn endpoint:

- L3 (first-turn-has-no-session-id): the agent_sessions row is created
  server-side via POST /session BEFORE the turn endpoint ever calls
  execute_task. No frontend-first model.
- L2 (cold turn writes empty JSONL): persist_session=True is passed
  unconditionally — Phase 1.4 already wired the flag through the agent
  stack; Phase 2 just promises to set it on every turn.
- L1 (parser misses system/init): trust result.session_id directly —
  Phase 1.3 fixed the parser. Scenario A confirms the captured UUID is
  the real Claude UUID end-to-end.

Phase 2.2 resume-failure fallback: when execute_task returns "no
conversation found" on a turn that had a cached UUID, clear the cache,
mark_resume_failure, and retry once with resume_session_id=None. Logs
event=session_resume_fallback with the stale UUID and consecutive
failure count. Anthropic #39667 (cleanupPeriodDays) and #53417 (CLI
upgrade) both produce this signal.

Phase 2.3 Redis lock: SET NX EX per (agent, claude_uuid) with 5-min TTL
and Lua-script release. Async poll loop (250ms tick) so the event loop
stays free during contention. Cold turns skip the lock (no JSONL to
corrupt). Hard 30s wait ceiling — beyond that the contender gets HTTP
429 with retry hint. Mitigation for Anthropic #20992 (concurrent
--resume JSONL writes corrupt the file).

Per-user ownership at the row layer: even agent owners cannot read or
send into another user's session (E6 isolation in the design doc).
Returns 404 for ownership failures so we don't leak session-id existence.

Tests (tests/integration/test_session_turns.py, run inside
trinity-backend container with docker.sock mounted for testfix
recreation + JSONL surgery in Scenario C):

  Scenario A: 3-turn happy path — same Claude UUID across turns
  Scenario B: turn 2 recalls a secret from turn 1, no text-replay
  Scenario C: JSONL deletion mid-session triggers fallback + recovery
  Scenario D: concurrent POSTs serialise via Redis lock
              (asserts finish_gap ≈ winner_work_time, NOT total wall)
  Scenario E: switching sessions A → B → A preserves A's UUID

5 passed in 54.5s against the live agent-testfix container (recreated
onto the rebuilt base image first per L4 in the plan). Phase 1's 25
unit tests still pass — no regressions.

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

* feat(session-tab): frontend Session surface

Phase 3 of docs/planning/SESSION_TAB_2026-04.md. Adds the new "Session"
tab in AgentDetail, gated on the is_session_tab_enabled() platform flag
so it stays invisible until explicit opt-in (default off).

Backend prerequisite — routers/settings.py:

- GET /api/settings/feature-flags exposes a curated allowlist of UI-
  relevant flags to any authed user. The existing /api/settings/{key}
  endpoint is admin-only and would block non-admin frontends from even
  knowing whether to render the Session tab. The new endpoint reads
  through services.settings_service.is_session_tab_enabled() so the
  resolution order (DB → env → False) stays in one place.

Frontend:

- src/frontend/src/stores/sessions.js — Pinia store wrapping the six
  /api/agents/{name}/sessions* endpoints with per-agent state isolation
  and the feature-flag cache. Optimistic user-message insert with
  rollback on send failure.

- src/frontend/src/components/SessionPanel.vue — structural copy of
  ChatPanel reusing ChatMessages + ChatInput + ModelSelector. Differs
  from Chat in three places per the design doc:
    * Sends bare user_message to POST .../sessions/{id}/message — no
      buildContextPrompt text-replay (the agent already has working
      memory via --resume).
    * "Reset memory" button + confirm modal that clears the cached
      Claude UUID without deleting the message log (Phase 3.4).
    * Per-session selector subtitle: turn count, context % used,
      cached-memory dot (emerald/gray), and consecutive_resume_failures
      indicator (Phase 3.5).
  Lean cut for first-visible-surface: voice mic, file upload, and SSE
  dynamic status labels are deferred — those need backend extensions
  (file payload on the turn endpoint, async_mode + SSE on the same).

- src/frontend/src/views/AgentDetail.vue — new Session tab inserted
  between Chat and Dashboard/Schedules, gated on
  sessionsStore.sessionTabEnabled. Layout sites that previously
  branched on activeTab === 'chat' now use a shared isFullscreenTab
  computed so Chat and Session both get the input-pinned-to-bottom flex
  layout. ?tab=session deep-link allowlist updated.

- src/frontend/e2e/session-tab.spec.js — Phase 3.6 Playwright spec.
  Marked @interactive (not @smoke) because each run makes one real
  Claude API call (~10–60s). Snapshots the prior flag value in
  beforeAll, force-enables for the run, restores in afterAll so a
  failed run doesn't leave the platform with the flag dirty. Three
  cases:
    * tab is hidden when flag is off
    * tab appears, "+ New Session" → send turn → reply visible →
      Reset memory modal opens + closes
    * Chat tab still works after Session interaction; switching back
      preserves Session state

Visually verified in the live dev server: tab renders in correct
position, header layout matches Chat's structure, empty state and
placeholder copy match the design doc, "Reset memory" only shown when
an active session exists, full-viewport flex layout pins input to
bottom.

Phase 1 + Phase 2 work behind this change is unchanged: 25 unit tests
+ 5 integration tests still green.

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

* feat(session-tab): hardening + observability — cleanup service, contamination gate, docs

Phase 4 of docs/planning/SESSION_TAB_2026-04.md. Closes the JSONL
disk-growth loop, validates the GA-blocking cross-session contamination
hypothesis empirically, and lands architecture.md / feature-flows
documentation so the surface is discoverable.

Phase 4.3 — cross-session contamination GA gate (the load-bearing one):

- tests/integration/test_session_cross_contamination.py exercises the
  Anthropic #26964 hypothesis end-to-end. Plants a randomly-generated
  secret token in session A with explicit "do not echo" framing, asks
  session B (different UUID, same agent, same cwd) to recall the token.
  Hard-fails if the exact token leaks; soft-fails on partial-prefix
  recall (PURPLE-DRAGON without the random suffix would only be
  knowable from A's JSONL, not from training).
- PASSED in 9.5s on the current Claude Code version → shared-cwd model
  is safe → Phase 5 rollout unblocked. Test stays in the suite as the
  per-version regression guard.

Phase 4.2 — JSONL cleanup service:

- services/session_cleanup_service.py runs a 6h periodic sweep that
  diffs every running agent's
  ~/.claude/projects/-home-developer/<uuid>.jsonl set against
  db.list_active_claude_session_ids(agent) and reaps orphans whose
  mtime is older than the 1h race guard. Race guard prevents the
  cold-turn-vs-cleanup window where a brand-new JSONL exists on disk
  before the backend has updated cached_claude_session_id.
- Same service exposes a synchronous reap_jsonl(agent, uuid) helper
  called best-effort from routers/sessions.py reset/delete handlers so
  the user-perceived disk-reclaim latency is sub-second. Never raises;
  failures are logged and the periodic sweep is the safety net.
- Implementation uses execute_command_in_container — the same primitive
  git_service / ssh_service / scheduler pre-check / agent terminal use.
  No new agent-server endpoint, no base-image rebuild.
- New db.list_active_claude_session_ids(agent) facade method backed by
  SessionOperations.list_active_claude_session_ids querying every
  agent_sessions row whose cached_claude_session_id is non-null for the
  agent.
- main.py wires startup (staggered +7.5s after cleanup_service to
  offset Docker hits) and clean shutdown.
- tests/integration/test_session_cleanup.py: reset reaps synchronously,
  delete reaps synchronously, periodic sweep keeps the active JSONL,
  reaps an aged orphan, respects the 1h race guard for fresh orphans.

Phase 4.4 — architecture.md updates:

- Background Services table gets a Session Cleanup row.
- New "Session Tab" subsection in API Endpoints documenting all six
  /api/agents/{name}/sessions* routes including the per-user ownership
  rule (404 not 403) and the resume-failure fallback / Redis lock.
- New /api/settings/feature-flags row.
- New agent_sessions / agent_session_messages DDL block in Database
  Schema, with the three Session-specific fields called out
  (cached_claude_session_id, consecutive_resume_failures,
  cache_read_tokens, claude_session_id audit).

Phase 4.5 — feature-flows/session-tab.md vertical slice:

- Full path from UI → API → DB → Side Effects with the JSONL lifecycle
  table, the spike-pitfall defense map (L1/L2/L3/#20992/#26964), the
  error-handling matrix, and the complete test catalog with the docker
  run command for the integration suite.
- feature-flows.md index updated (Recent Updates row + Chat & Sessions
  section entry).

Test totals: 25 unit + 9 integration = 34 tests, all green. Phase 4.3
serves as both the GA gate and the per-Claude-version regression guard.

Phase 4.1 (cache_read_tokens UI surfacing) deferred — the column is
already populated by the Phase 2 turn endpoint; surfacing is a minor
observability follow-up that doesn't block Phase 5.

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

* feat(session-tab): tag Session-tab turns with triggered_by="session" and a gold badge

Previously Session-tab turns went into schedule_executions with
triggered_by="chat", so the Tasks tab couldn't tell them apart from
the Chat tab. The user-visible signal was that every Session turn
showed up under the sky-blue "chat" badge.

Backend (routers/sessions.py): both call sites that invoke
task_execution_service.execute_task — the cold/resume turn and the
resume-failure fallback retry — now pass triggered_by="session".
Existing rows are unchanged; the cutover is per-write.

Frontend (TasksPanel.vue): adds a "Session" option between "Chat" and
"Manual" in the trigger filter dropdown, plus an amber/gold badge
branch (bg-amber-100 dark:bg-amber-900/30 text-amber-700
dark:text-amber-300) — visually distinct from "paid" (bright yellow)
and from the sky-blue "chat" badge.

triggered_by is a free-form TEXT column (no enum constraint at the DB
or service layer), so adding "session" as a new value doesn't require
any migration or downstream consumer updates. Filter, badge, audit
log, activity stream, and dashboards all just see another value and
display it; nothing has to know about it explicitly.

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

* fix(session-tab): correct context-window accounting + raise frontend turn timeout

Five interrelated fixes from manual testing — all about the per-turn
"context %" metric being misleading and the browser timing out before
long-running session turns finished.

1) Agent server (docker/base-image/agent_server/services/claude_code.py)
   process_stream_line's `result` event handler used to overwrite
   metadata.input_tokens, cache_read_tokens, and cache_creation_tokens
   with the values from result.usage. Those values are CUMULATIVE
   across every internal API call the turn made (Claude Code packs
   tool-use loops into a single user turn that maps to N internal API
   calls). For an 18-iteration turn each reading the same 70K cached
   prefix, result.usage.cache_read_input_tokens = 18 * 70K = 1.26M
   tokens — billing-cumulative, not the prompt size of any single call.
   Overwriting per-message values with that aggregate made our
   context-window-pressure metric grow far beyond the 200K limit even
   when no individual API call was anywhere close to the wall.

   Fix: result handler now only extracts model-level facts (cost,
   duration, num_turns, session_id, error info, modelUsage.contextWindow).
   Per-API-call usage stays in the per-assistant-message handler, where
   the LATEST message's values represent the FINAL API call's prompt
   size — exactly what determines whether the next turn will fit.

   Also added a per-message usage-extraction block to the assistant
   branch of process_stream_line (it previously had no usage extraction
   at all, relying entirely on the result handler — which made my
   first attempt at this fix produce zero values). parse_stream_json_output
   already had the equivalent block (lines 211-215).

   Base image rebuilt; agent-testfix recreated onto the new image
   (image sha 0a1e20b40da1).

2) Backend (services/task_execution_service.py)
   Replaced `context_used = metadata.input_tokens` with
   `cache_read + cache_creation` (with input_tokens fallback when
   caching isn't engaged). input_tokens is sometimes the disjoint
   fresh value and sometimes inflated by the agent server's
   modelUsage.inputTokens override on tool-call turns. cache_read
   and cache_creation come straight from Anthropic's usage object
   and (post agent-server fix) are reliable per-call values that
   monotonically reflect the cached conversation prefix.

3) DB (db/sessions.py)
   total_context_used is now a HIGH-WATERMARK (MAX of prior + new),
   not the latest value. Per-turn context naturally oscillates by ~2x
   between text-only and tool-call turns; the watermark gives users
   a stable monotonic upper bound on session pressure that only goes
   up.

   Capped the watermark at total_context_max as a safety belt against
   any future agent-server bug that emits cumulative-billing token
   counts. Genuine per-call peaks should never exceed the model's
   context window — if they do, that's an accounting error not a
   real overflow, and the UI should display 100% rather than 648%.

4) Frontend (stores/sessions.js)
   Bumped the Axios timeout on the session turn endpoint from 305s
   (~5 min) to 7260s (= TIMEOUT-001 cap of 7200s + 60s slack). The
   session turn endpoint is synchronous and may legitimately run for
   the agent's full execution timeout. With the previous 305s ceiling
   the browser threw a misleading "failed" toast on tool-heavy turns
   that ran longer; the response still landed in the DB and the UI
   recovered after a page refresh, but the user saw a phantom error.

Verified end-to-end with a 6-turn mixed sequence (text + tool-call):
per-call cache_read now reports ~11636 on text-only turns and ~18000
on tool…
vybe added a commit that referenced this pull request Jun 1, 2026
* refactor(frontend): sweep auth + channels + top-level views (#554) (#609)

* refactor(frontend): sweep auth + channels + top-level views (#554)

Slice 4 — bundles three subdomains into one PR per request:

  AUTH (3 files, 24 migrations)
    - Login.vue           — error icon
    - SetupPassword.vue   — password match indicator, requirement checklist,
                            error banner, 6-tier strength visualization
                            (red/red/orange/yellow/green/green)
    - MobileAdmin.vue     — logout, status dot, fleet running/high-context
                            counts, chat thinking status

  CHANNELS (5 files, 23 migrations)
    - PublicLinksPanel    — Active badge, expired text, Slack
                            connected/enable/disable/delete icons,
                            form error, delete-confirm modal,
                            success toast
    - WhatsAppChannelPanel — connected dot, sandbox badge, disconnect btn,
                             webhook warning, success/error message
    - TelegramChannelPanel — connected dot, disconnect btn, webhook
                             warning, group-remove, success/error message
    - SlackChannelPanel   — connected dot, disconnect btn, success/error
    - SharingPanel        — Approve button, success/error message,
                            remove button

  TOP-LEVEL VIEWS (4 files, 33 migrations)
    - Dashboard.vue       — running count + dot, message count, clear-tags,
                            connection status dot, history badge,
                            live-feed indicator, message arrow icon
    - PublicChat.vue      — agent online dot, invalid-link icon + bg,
                            agent-unavailable icon + bg, verify error,
                            chat error
    - OperatingRoom.vue   — empty-state success indicator
    - Templates.vue       — error icon

80 token replacements; net diff +80 / -80 (all 1:1 palette aliases).

Deferred (30 raw refs remain; all need new token families):
  - Login: 8 blue (primary action buttons + focus rings)
  - Dashboard: 10 (blue actions, purple tag-cloud button, blue selected-tab)
  - OperatingRoom: 5 (blue selected-tab indicators)
  - PublicChat: 5 (amber AUTO badge, rose READ-ONLY badge, indigo loading)
  - WhatsApp + Sharing: 2 (amber "deployment prerequisite" notices)

These map to pending `action-primary`, `state-selected`, and an accent
expansion that's tracked under #555 follow-up territory.

Tests:
  No new specs — these routes are either auth pages (covered by
  auth.setup), already smoke-tested (Templates), or require fixtures
  (PublicChat needs a public link token; MobileAdmin lives at /m).
  Existing 6 @smoke tests cover the high-traffic routes.

Refs #554

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

* fix(vite): tighten /api proxy prefix to /api/

`/api` (no trailing slash) is a path-prefix match in http-proxy-middleware,
so the SPA route `/api-keys` was being captured by the proxy and forwarded
to the backend, returning 404 in dev mode.

This silently broke the `/api-keys` @smoke e2e test on every PR since the
test was added in #597 — that PR's frontend-e2e check failed on merge but
wasn't required, so the failure was missed.

Backend endpoints all live under `/api/...` (with slash), so the tighter
prefix preserves all real proxy traffic and only excludes the SPA route.

Verified locally: 7/7 @smoke tests pass after this change (was 6/7 with
/api-keys failing).

Refs #554 #556

---------

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

* refactor(frontend): sweep cross-cutting + chat + file-mgr + process + misc (#554) (#623)

* refactor(frontend): sweep auth + channels + top-level views (#554)

Slice 4 — bundles three subdomains into one PR per request:

  AUTH (3 files, 24 migrations)
    - Login.vue           — error icon
    - SetupPassword.vue   — password match indicator, requirement checklist,
                            error banner, 6-tier strength visualization
                            (red/red/orange/yellow/green/green)
    - MobileAdmin.vue     — logout, status dot, fleet running/high-context
                            counts, chat thinking status

  CHANNELS (5 files, 23 migrations)
    - PublicLinksPanel    — Active badge, expired text, Slack
                            connected/enable/disable/delete icons,
                            form error, delete-confirm modal,
                            success toast
    - WhatsAppChannelPanel — connected dot, sandbox badge, disconnect btn,
                             webhook warning, success/error message
    - TelegramChannelPanel — connected dot, disconnect btn, webhook
                             warning, group-remove, success/error message
    - SlackChannelPanel   — connected dot, disconnect btn, success/error
    - SharingPanel        — Approve button, success/error message,
                            remove button

  TOP-LEVEL VIEWS (4 files, 33 migrations)
    - Dashboard.vue       — running count + dot, message count, clear-tags,
                            connection status dot, history badge,
                            live-feed indicator, message arrow icon
    - PublicChat.vue      — agent online dot, invalid-link icon + bg,
                            agent-unavailable icon + bg, verify error,
                            chat error
    - OperatingRoom.vue   — empty-state success indicator
    - Templates.vue       — error icon

80 token replacements; net diff +80 / -80 (all 1:1 palette aliases).

Deferred (30 raw refs remain; all need new token families):
  - Login: 8 blue (primary action buttons + focus rings)
  - Dashboard: 10 (blue actions, purple tag-cloud button, blue selected-tab)
  - OperatingRoom: 5 (blue selected-tab indicators)
  - PublicChat: 5 (amber AUTO badge, rose READ-ONLY badge, indigo loading)
  - WhatsApp + Sharing: 2 (amber "deployment prerequisite" notices)

These map to pending `action-primary`, `state-selected`, and an accent
expansion that's tracked under #555 follow-up territory.

Tests:
  No new specs — these routes are either auth pages (covered by
  auth.setup), already smoke-tested (Templates), or require fixtures
  (PublicChat needs a public link token; MobileAdmin lives at /m).
  Existing 6 @smoke tests cover the high-traffic routes.

Refs #554

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

* fix(vite): tighten /api proxy prefix to /api/

`/api` (no trailing slash) is a path-prefix match in http-proxy-middleware,
so the SPA route `/api-keys` was being captured by the proxy and forwarded
to the backend, returning 404 in dev mode.

This silently broke the `/api-keys` @smoke e2e test on every PR since the
test was added in #597 — that PR's frontend-e2e check failed on merge but
wasn't required, so the failure was missed.

Backend endpoints all live under `/api/...` (with slash), so the tighter
prefix preserves all real proxy traffic and only excludes the SPA route.

Verified locally: 7/7 @smoke tests pass after this change (was 6/7 with
/api-keys failing).

Refs #554 #556

* refactor(frontend): sweep cross-cutting + chat + file-mgr + process + misc (#554)

Slice 7 stacked on #609. Migrates 24 files spanning the small-domain pool:

  CHAT (4 files)
    ChatBubble  — copy-success checkmark, self-task accent panel (purple)
    ChatPanel   — agent-not-running warning state, error banner
    ChatInput   — file-remove button, voice-active recording indicator
    ChatHistoryDropdown — error state

  FILE MANAGER (4 files)
    FileManager        — notification toast (success/error), no-agents warning,
                         loading error, delete button + modal + confirm action
    FileTreeNode       — search-matched row highlight (file-type icons stay raw
                         decorative; need their own accent palette later)
    FilePreview        — preview-error icon + text
    FileSharingPanel   — Revoke button

  PROCESS (3 files)
    TrendChart        — completed/failed/cost bars (chart series + legend),
                         success-rate threshold ladder
    RoleMatrix        — no-executor row + badge
    TemplateSelector  — category badges (business/devops/support → status-info /
                         accent-purple / status-urgent)

  CROSS-CUTTING / MODALS (10 files)
    NavBar               — Ops critical-pulse + high indicator, WS connected dot
    GitConflictModal     — yellow warning header (×2), all destructive (red) options
    ReplayTimeline       — system-agent purple panel + badge, schedule-marker arrow,
                            live-feed dot, activity-state success rate ladder
    UnifiedActivityPanel — running/success/fail indicators (live + modal)
    OnboardingChecklist  — completed-state styling (ring, bg, indicator, text)
    CreateAgentModal     — templates-error + general error
    ConfirmDialog        — danger/warning variant icons, text, confirm buttons
    ResourceModal        — (no migrations — amber notice, deferred)
    AvatarGenerateModal  — error text, remove-avatar button
    HelpChatWidget       — error banner + retry button

  MISC (3 files)
    YamlEditor          — error and warning banners + counts + success checkmark
    EditorHelpPanel     — required-field indicator
    TerminalPanelContent — restart-required notice, start-agent button
    TagsEditor          — error message

Net diff: 24 files, +106 / -106 (1:1 palette aliases, byte-identical CSS).

Deferred (existing pattern):
  - Indigo / blue primary action buttons (Login & elsewhere)
  - Blue selected-state (NavBar tabs, OperatingRoom tabs)
  - Amber notices (ResourceModal, RoleMatrix amber missing-role marker —
    these still use the amber palette which differs from yellow)
  - File-type icon colors in FileTreeNode (decorative, need accent-yellow /
    accent-purple-blue / etc. — folder ≠ warning, video ≠ accent, etc.)
  - Slack/Telegram/WhatsApp logo brand colors

These map to the pending `action-primary`, `state-selected`, and accent-color-
expansion tickets.

Verified locally:
  - npm run check:tokens                         passes (10 tokens valid)
  - npm run build                                passes
  - npm run test:e2e:smoke (7 tests, 7.9s)       all green

Refs #554

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

---------

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

* refactor(frontend): sweep agent surfaces — 22 files (#554) (#614)

* refactor(frontend): sweep auth + channels + top-level views (#554)

Slice 4 — bundles three subdomains into one PR per request:

  AUTH (3 files, 24 migrations)
    - Login.vue           — error icon
    - SetupPassword.vue   — password match indicator, requirement checklist,
                            error banner, 6-tier strength visualization
                            (red/red/orange/yellow/green/green)
    - MobileAdmin.vue     — logout, status dot, fleet running/high-context
                            counts, chat thinking status

  CHANNELS (5 files, 23 migrations)
    - PublicLinksPanel    — Active badge, expired text, Slack
                            connected/enable/disable/delete icons,
                            form error, delete-confirm modal,
                            success toast
    - WhatsAppChannelPanel — connected dot, sandbox badge, disconnect btn,
                             webhook warning, success/error message
    - TelegramChannelPanel — connected dot, disconnect btn, webhook
                             warning, group-remove, success/error message
    - SlackChannelPanel   — connected dot, disconnect btn, success/error
    - SharingPanel        — Approve button, success/error message,
                            remove button

  TOP-LEVEL VIEWS (4 files, 33 migrations)
    - Dashboard.vue       — running count + dot, message count, clear-tags,
                            connection status dot, history badge,
                            live-feed indicator, message arrow icon
    - PublicChat.vue      — agent online dot, invalid-link icon + bg,
                            agent-unavailable icon + bg, verify error,
                            chat error
    - OperatingRoom.vue   — empty-state success indicator
    - Templates.vue       — error icon

80 token replacements; net diff +80 / -80 (all 1:1 palette aliases).

Deferred (30 raw refs remain; all need new token families):
  - Login: 8 blue (primary action buttons + focus rings)
  - Dashboard: 10 (blue actions, purple tag-cloud button, blue selected-tab)
  - OperatingRoom: 5 (blue selected-tab indicators)
  - PublicChat: 5 (amber AUTO badge, rose READ-ONLY badge, indigo loading)
  - WhatsApp + Sharing: 2 (amber "deployment prerequisite" notices)

These map to pending `action-primary`, `state-selected`, and an accent
expansion that's tracked under #555 follow-up territory.

Tests:
  No new specs — these routes are either auth pages (covered by
  auth.setup), already smoke-tested (Templates), or require fixtures
  (PublicChat needs a public link token; MobileAdmin lives at /m).
  Existing 6 @smoke tests cover the high-traffic routes.

Refs #554

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

* fix(vite): tighten /api proxy prefix to /api/

`/api` (no trailing slash) is a path-prefix match in http-proxy-middleware,
so the SPA route `/api-keys` was being captured by the proxy and forwarded
to the backend, returning 404 in dev mode.

This silently broke the `/api-keys` @smoke e2e test on every PR since the
test was added in #597 — that PR's frontend-e2e check failed on merge but
wasn't required, so the failure was missed.

Backend endpoints all live under `/api/...` (with slash), so the tighter
prefix preserves all real proxy traffic and only excludes the SPA route.

Verified locally: 7/7 @smoke tests pass after this change (was 6/7 with
/api-keys failing).

Refs #554 #556

* refactor(frontend): sweep cross-cutting + chat + file-mgr + process + misc (#554)

Slice 7 stacked on #609. Migrates 24 files spanning the small-domain pool:

  CHAT (4 files)
    ChatBubble  — copy-success checkmark, self-task accent panel (purple)
    ChatPanel   — agent-not-running warning state, error banner
    ChatInput   — file-remove button, voice-active recording indicator
    ChatHistoryDropdown — error state

  FILE MANAGER (4 files)
    FileManager        — notification toast (success/error), no-agents warning,
                         loading error, delete button + modal + confirm action
    FileTreeNode       — search-matched row highlight (file-type icons stay raw
                         decorative; need their own accent palette later)
    FilePreview        — preview-error icon + text
    FileSharingPanel   — Revoke button

  PROCESS (3 files)
    TrendChart        — completed/failed/cost bars (chart series + legend),
                         success-rate threshold ladder
    RoleMatrix        — no-executor row + badge
    TemplateSelector  — category badges (business/devops/support → status-info /
                         accent-purple / status-urgent)

  CROSS-CUTTING / MODALS (10 files)
    NavBar               — Ops critical-pulse + high indicator, WS connected dot
    GitConflictModal     — yellow warning header (×2), all destructive (red) options
    ReplayTimeline       — system-agent purple panel + badge, schedule-marker arrow,
                            live-feed dot, activity-state success rate ladder
    UnifiedActivityPanel — running/success/fail indicators (live + modal)
    OnboardingChecklist  — completed-state styling (ring, bg, indicator, text)
    CreateAgentModal     — templates-error + general error
    ConfirmDialog        — danger/warning variant icons, text, confirm buttons
    ResourceModal        — (no migrations — amber notice, deferred)
    AvatarGenerateModal  — error text, remove-avatar button
    HelpChatWidget       — error banner + retry button

  MISC (3 files)
    YamlEditor          — error and warning banners + counts + success checkmark
    EditorHelpPanel     — required-field indicator
    TerminalPanelContent — restart-required notice, start-agent button
    TagsEditor          — error message

Net diff: 24 files, +106 / -106 (1:1 palette aliases, byte-identical CSS).

Deferred (existing pattern):
  - Indigo / blue primary action buttons (Login & elsewhere)
  - Blue selected-state (NavBar tabs, OperatingRoom tabs)
  - Amber notices (ResourceModal, RoleMatrix amber missing-role marker —
    these still use the amber palette which differs from yellow)
  - File-type icon colors in FileTreeNode (decorative, need accent-yellow /
    accent-purple-blue / etc. — folder ≠ warning, video ≠ accent, etc.)
  - Slack/Telegram/WhatsApp logo brand colors

These map to the pending `action-primary`, `state-selected`, and accent-color-
expansion tickets.

Verified locally:
  - npm run check:tokens                         passes (10 tokens valid)
  - npm run build                                passes
  - npm run test:e2e:smoke (7 tests, 7.9s)       all green

Refs #554

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

* refactor(frontend): sweep agent surfaces — 22 files (#554)

Slice 8 of the design-system migration. Replaces semantic palette refs
with status/state/accent tokens across all per-agent panels, the
agents list, and agent-detail surfaces.

Files migrated:
- 16 panels (Tasks, Git, Dashboard, Playbooks, Nevermined, Info,
  Credentials, Schedules, SystemViewEditor, HostTelemetry, Folders,
  Files, Skills, Observability, Permissions, Metrics)
- 5 agent surfaces (Agents, AgentNode, AgentHeader, AgentTerminal,
  AgentDetail)
- SystemAgentNode

Mappings (palette-equivalent, no visual change):
- yellow → status-warning
- green  → status-success
- red    → status-danger
- orange → status-urgent
- amber  → state-autonomous (token name slightly stretched for tool-call
  / queued-task amber; same palette, future cleanup may rename)
- rose   → state-locked
- purple → accent-purple

Deferred (no token family yet):
- indigo (action-primary)
- blue/sky/cyan (selected-state, category labels)
- teal (category labels)

SystemViewsSidebar untouched — only contains deferred blue/indigo
selected-state references.

Verification:
- npm run check:tokens → 10 tokens equivalent, all references resolve
- npm run build → clean
- npm run test:e2e:smoke → 7/7 passed against live Trinity (HMR)

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-runtime): kill npx MCP orphans outside claude pgid that hold stdout pipe open (#618) (#620)

* fix(voice): orb animation loop dies before voice session starts

The canvas is inside v-if="voice.isActive.value" so canvasEl.value is
null when onMounted fires. renderFrame() exits early without scheduling
the next frame, killing the loop permanently.

Replace onMounted initialization with watch(canvasEl) so the RAF loop
starts when the canvas enters the DOM and stops when it leaves.

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

* fix(agent-runtime): kill npx MCP orphans outside claude pgid that hold stdout pipe open (#618)

After terminate_process_group kills claude's pgid, npm→node MCP server chains
spawned via npx call setsid() and land in a new session — they survive the
pgid kill and keep the stdout pipe write FD open indefinitely.  The kernel
cannot deliver EOF to our reader thread while any writer FD remains open, so
drain_reader_threads blocked for the full 30s post_kill_grace, then lost the
buffered result line via force-close (HTTP 502).

Add _kill_orphan_pipe_writers(): after terminate_process_group, scan /proc/*/fd
for any process outside our pgid that holds the pipe's write end (detected via
fdinfo flags), and SIGKILL it.  Killing the orphan releases all its FDs
(stdout AND stderr write ends) simultaneously, delivering EOF to both reader
threads so they drain naturally before the post_kill_grace window.

New tests (Linux-only, skipped on macOS — /proc required): verify that a
setsid() grandchild is detected and killed, that our own read-end process is
not touched, and that the end-to-end drain path preserves buffered data.

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

---------

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

* fix(slack): replace slackify-markdown with own renderer (#293) (#622)

Replaces slackify-markdown with a custom renderer fixing 5 compounding bugs (nested lists, headings, blockquotes, tables, horizontal rules). Includes 35 unit tests and updated feature flow doc.

Closes #293

Co-Authored-By: pavshulin <pavshulin@users.noreply.github.com>

* feat(agents): per-agent token usage display in AgentHeader (#250) (#632)

Adds a token usage row to AgentHeader showing 7-day cost sparkline,
today's cost vs 7-day daily average (with trend arrow), and lifetime
totals. Data sourced from schedule_executions in the DB so it persists
across agent restarts.

- New GET /api/agents/{name}/token-stats endpoint
- ScheduleOperations.get_agent_token_stats(): single-pass 24h/7d/lifetime
  aggregation + 7-day daily breakdown with gap-filling
- agentsStore.getAgentTokenStats() action
- TOKEN USAGE ROW in AgentHeader.vue: SparklineChart (amber, 56x16),
  trend indicator (warning/success/gray), lifetime summary
- Hidden for agents with no runs (lifetime_executions == 0)

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

* feat(site): agent website proxy via /site/{token} endpoint (SITE-001) (#634)

Adds live HTTP reverse-proxy so agents can serve public websites from
their container. A new `type='site'` public link routes requests through
`GET /site/{token}/{path}` → httpx streaming proxy → agent web server at
`http://agent-{name}:3000`. Includes DB migration, nginx routing, rate
limiting (per-IP + per-token), SSRF guard, security header stripping, and
audit event `site_link_visit`. UI adds Chat/Website selector in the link
create modal with a "Website" badge on site links.

Fixes #633

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

* fix(site): centralize SITE_PORT, atomic rate limit, fire-and-forget audit log, update docs (SITE-001)

- Move SITE_PORT to config.py; import in site.py and public_links.py
- Fix TOCTOU race in _check_site_rate_limit: pipeline INCR+check-after
- Audit log is now asyncio.create_task() so streaming is not delayed
- Add SITE-001 to requirements.md (section 15.1a-3)
- Add site.py to architecture.md router listing + /site/ endpoint table

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

* fix(agent-runtime): bound _kill_orphan_pipe_writers to 10s to prevent drain stall (#649) (#650)

/proc scanning can block indefinitely when a process is in D state
(uninterruptible sleep), causing drain_reader_threads to stall for
tens of minutes instead of the expected ~30 seconds.

Run _kill_orphan_pipe_writers in a daemon thread with a 10s cap so
a blocked /proc entry cannot push the drain past its deadline.

Also use wall-clock accounting for the post-kill join timeout so time
spent in terminate + orphan scan doesn't silently erode the budget,
and log actual elapsed time instead of the expected value so future
incidents are easier to diagnose.

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

* fix(security): voice WebSocket + stop endpoint missing ownership check (#600) (#638)

The /ws/voice/{voice_session_id} handler decoded the JWT but threw the
payload away — only the signature was checked. Any authenticated user
holding a valid JWT who learned the 128-bit session id (logs, browser
inspection, XSS) could attach to the audio stream, eavesdrop on the
victim's transcript, and trigger tool calls audit-logged under the
victim's identity.

POST /api/agents/{name}/voice/stop had the same gap: the path agent was
gated via get_authorized_agent, but request.voice_session_id was never
cross-checked against the path agent or the caller's user_id, so the
caller could end and persist a transcript onto another user's session.

Fix:
- WS: extract sub from the decoded JWT, look up the user, and close 4003
  if user.id != session.user_id (admin role bypasses, for support).
- voice_stop: load the session via get_session before mutating, assert
  agent_name == path name AND user_id == current_user.id (admin bypasses),
  raise 403 otherwise.

Added tests/unit/test_voice_auth.py covering: missing token, invalid
token, missing sub claim, unknown user, owner happy path, admin bypass,
attacker rejected, plus voice_stop variants. Loads voice.py via
importlib to avoid pulling in the full routers/__init__.py chain.

Reported by /security-review on PR #599 (2026-04-30); origin commit
7d8abe8 (#581 voice tool calls).

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

* fix(auth): split login rate limit into per-account + per-IP buckets (#591) (#621)

Pentest finding AISEC-H2 (CVSS 7.5, CWE-307): the previous design used a
single per-IP bucket at 5 fails / 10 min. Any user behind a corporate
NAT, VPN, or CDN locked out everyone else at the same egress IP after
just four bad attempts. A rotating-proxy attacker could keep an
organisation locked out continuously, so the protection doubled as a
platform-wide DoS primitive.

Replace with two independent buckets:

  * Per-account (tight) — 5 fails / 15 min: limits credential stuffing
    on one targeted account; never affects other accounts.
  * Per-IP (loose)      — 30 fails / 5 min: catches single-source abuse
    but stays well above the legitimate-traffic threshold for users
    sharing a NAT/VPN/CDN egress.

Both buckets are checked on every attempt; 429 fires when either is
exhausted. Successful login clears both. Account names are normalised
(lowercase + strip) before keying. Endpoints without an account context
(public access-request) skip the per-account bucket and rely on the
per-IP one only.

Lockout state-changes log a structured WARNING (visible via Vector) so
operators can see when buckets are being exercised.

Live verification on the running backend:
  attempts 1-5 → 401 (counter ticking)
  attempt  6   → 429 "Too many failed attempts for this account..."
  valid pwd    → 429 (account stays locked even with right password)
  other account from same IP → 401 (per-account isolation works)

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

* fix(security): require creator role on /api/systems/deploy (#592) (#624)

Auditing all entry points that ultimately call `create_agent_internal`
(per #592 AC #2) turned up a real bypass on `POST /api/systems/deploy`.
The system-manifest deployment route gated on `Depends(get_current_user)`
without a role check, so any authenticated user-role account could spawn
an entire fleet of agents through that path — a strictly stronger
privilege than the single-agent bypass the AISEC-H1 finding originally
named (which `POST /api/agents/deploy-local` had already closed via #150).

Add `Depends(require_role("creator"))` to `deploy_system`, matching the
existing dependencies on `POST /api/agents` and `POST /api/agents/deploy-local`.

Regression test (`tests/unit/test_agent_creation_role_gates.py`) walks
the FastAPI router source AST and asserts that every agent-creation
route uses `Depends(require_role("creator"))`. AST-level so the check is
fast, stable across formatting changes, and fires the moment someone
removes the dependency. Confirmed the test catches the regression by
reverting the change and observing the test fail before re-applying.

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

* fix(security): stop mirroring JWT into document.cookie (#188) (#642)

* fix(security): stop mirroring JWT into document.cookie (#188)

UnderDefense pentest 3.3.5 flagged the frontend for mirroring the
authentication token into a `token` cookie without `Secure` or
`HttpOnly` flags. The cookie was set in setupAxiosAuth via
`document.cookie =` so it was readable from JS (HttpOnly is impossible
on JS-set cookies), transmitted over HTTP without the Secure flag, and
auto-attached to every outbound request as a CSRF vector.

The cookie's stated purpose was "for nginx auth_request to validate
agent UI access" — but that nginx directive was never configured in
any committed deployment (`grep -r auth_request -- *.conf` is empty,
git log -S confirms it never existed). The cookie was pure attack
surface with zero functional value.

Per the issue's "Best" remediation, drop the cookie mirror entirely.
API authentication uses the `Authorization: Bearer` header
exclusively; nothing else needs the cookie.

The cookie-clear on logout is intentionally kept so users carrying a
stale cookie from the pre-fix version get cleaned up on their next
logout cycle. The cookie's `max-age=1800` also naturally expires it
within 30 minutes of the upgrade.

The backend's `/api/auth/validate` endpoint still accepts a cookie as
one of three token sources — left untouched as out-of-scope. With the
frontend no longer setting the cookie nothing legitimate sends one,
but the fallback path remains available if a future nginx
auth_request setup is wired up properly (with Secure + HttpOnly flags
set server-side via Set-Cookie, not via document.cookie).

Closes #188.

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

* docs(admin-login): remove stale references to JWT cookie mirror (#188)

PR #642 removed the document.cookie set in setupAxiosAuth, but the
admin-login feature flow still documented the cookie as live. Update
the code snippet and the storage table to reflect current behaviour.

Note in the snippet describes why the cookie was removed so readers
who see the diff history can find the rationale without reading the
PR.

---------

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

* fix(security): override git User-Agent on skills library sync (#184) (#646)

UnderDefense pentest 3.3.1 flagged the backend for leaking the
underlying tech stack via outbound User-Agent. Skills sync uses git
subprocess (not httpx), so the leaked UA is `git/<version>
(libcurl/<version> ...)` — verified live with GIT_TRACE_CURL=1.

Add `-c http.useragent=Trinity-Skills-Sync` (positioned correctly
before the subcommand, as git's `-c` requires) to the two HTTP-bearing
git invocations: `_git_clone` and `_git_pull`'s fetch. The local-only
`git reset --hard` and `git rev-parse HEAD` calls intentionally do
not get the flag — they make no HTTP and threading the flag through
would suggest otherwise.

The SSRF allowlist (#179) already locks the destination to github.com
so the practical exposure is small (GitHub already knows what we are),
but defense-in-depth: even if the allowlist is ever loosened the UA
stays generic.

The constant has no version suffix to avoid yet another version string
drifting against VERSION / package.json / pyproject.

Tests in tests/unit/test_skill_service_user_agent.py mock subprocess
and assert the flag is present at the right argv position for clone
and fetch, and absent for the local-only reset and rev-parse calls.

Live verification with `GIT_TRACE_CURL=1 git -c http.useragent=... ls-remote ...`
confirms the wire UA changes from `git/2.43.0` to `Trinity-Skills-Sync`.

Closes #184.

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

* docs(deploy): document stale-image symptom + recovery in start.sh and DEPLOYMENT.md (#557) (#626)

Self-hosted developers tracking `dev` occasionally pull a commit that
adds a new Python or Node dependency to one of the platform Dockerfiles,
re-run `start.sh`, and end up with new source running against an old
image's Python env. Uvicorn crashes with `ModuleNotFoundError`, compose
keeps respawning the worker, and `start.sh` reports success — leaving
the UI "Disconnected" with no obvious diagnosis.

Adopting Option B from #557 discussion (PR #625 closed): treat this as
a documentation problem rather than building auto-detection into the
critical-path startup script. Auto-detection has clear cost (Python
subprocess + Docker inspect on every cold start) and unclear benefit
(production deploys use `compose pull` and don't hit this; the affected
population is self-hosted devs whose recovery is one command).

Two changes:
- `scripts/deploy/start.sh`: append a 4-line hint after the "Ready!"
  banner naming the symptom (`ModuleNotFoundError`, "Disconnected" UI)
  and the exact recovery command.
- `docs/DEPLOYMENT.md`: add a Troubleshooting entry with full diagnosis
  walkthrough, root-cause explanation, and the rationale for not
  auto-detecting (links to #557).

A future `scripts/deploy/upgrade.sh` is the right place to bundle
backup + rebuild + start + verify for the explicit upgrade path; that
is bigger-than-#557 scope.

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

* fix(security): lock down Redis — auth + ACL + network split (#589)

* 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>

* fix(dev): update gitea overlay network name after #589 split

trinity-network no longer exists; gitea dev overlay must attach to
trinity-agent-network (the preserved agent-network name).

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

* fix(tests): use credentialed Redis URL in scheduler test_config fixture (#661)

After #589 hardened Redis auth, SchedulerConfig raises on bare redis:// URLs.
The test_config fixture bypassed the env-level patch in tests/conftest.py by
passing redis_url="redis://localhost:6379" directly.

Fixes #659

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

* Session tab — `--resume`-default chat surface (Closes #651) (#652)

* docs(planning): add Session tab design — --resume-default chat surface

Adds docs/planning/SESSION_TAB_2026-04.md, the comprehensive plan for a
new "Session" tab living alongside Chat. Sessions reattach to their own
Claude Code JSONL via --resume, preserving tool memory, mid-skill state,
and reasoning state across turns.

Plan covers:
- UI design (tab placement, multi-session model, +New Session, Reset memory)
- Data model (agent_sessions / agent_session_messages — parallel to chat)
- Backend architecture (separate router, single shared change to
  task_execution_service for persist_session plumbing)
- Phased rollout (foundation → backend → frontend → hardening → GA)
- Edge cases & failure-mode lessons baked in from a prior local spike
  (parser bug, --no-session-persistence dependency, cold-turn detection,
  port allocation)
- Test plan including the cross-session contamination test for
  Anthropic claude-code#26964
- Retention/cleanup policy, observability, security checklist
- Local-first workflow: implementation runs entirely on this branch
  until validation passes; only then does the standard SDLC engage
  (issue, push, PR)

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

* feat(db): add agent_sessions + agent_session_messages tables

Phase 1.1 of the Session tab plan (docs/planning/SESSION_TAB_2026-04.md).
Schema definitions go in db/schema.py for fresh installs; the matching
idempotent migration agent_sessions_tables in db/migrations.py upgrades
existing databases.

The schema mirrors chat_sessions / chat_messages but is strictly parallel
— no foreign keys, no shared columns, separate index namespace. Three
fields are unique to the session model:

- agent_sessions.cached_claude_session_id — the Claude Code session UUID
  the next turn will pass to ``--resume``
- agent_sessions.consecutive_resume_failures — drives the resume-failure
  fallback (Phase 2.2)
- agent_session_messages.cache_read_tokens — observability for whether
  Anthropic's prompt cache engaged

CASCADE on session delete cleans up message rows automatically.

Verified locally: backend restart applies the migration cleanly, tables
have 15 columns each with correct types/defaults/PKs, all four indexes
created, second restart confirms idempotency.

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

* feat(db): add SessionOperations for Session tab persistence

Phase 1.2 of the Session tab plan (docs/planning/SESSION_TAB_2026-04.md).

- Adds AgentSession and AgentSessionMessage Pydantic models in db_models.py
  with the new fields the Session tab needs beyond ChatSession/ChatMessage:
  cached_claude_session_id, last_resume_at, consecutive_resume_failures on
  the session row, and cache_read_tokens + claude_session_id on each message.

- Creates db/sessions.py with a SessionOperations class mirroring the
  ChatOperations shape: create_session, get_session, list_sessions,
  delete_session, add_session_message, get_session_messages, plus the
  Claude UUID cache helpers (get/update/clear_cached_claude_session_id)
  and resume health helpers (mark_resume_failure, mark_resume_success).

- Wires the new ops into the DatabaseManager facade alongside the
  existing _chat_ops, with one delegating method per public operation.

No router, no agent-server change, no frontend yet — those land in later
phases. Tables agent_sessions and agent_session_messages already exist
from the prior schema commit.

* feat(session-tab): backend foundation for --resume-default Session surface

Phases 1.3 through 1.7 of the Session tab plan
(docs/planning/SESSION_TAB_2026-04.md). Pure backend / agent-server work
behind a flag — no UI surface yet, no behavior change to Chat or any
existing /task caller.

Agent server (base image):

- Stream-json parser fix (Appendix B). Both parse_stream_json_output and
  process_stream_line now recognize {"type":"system","subtype":"init"}
  for session_id capture, with the result event as a fallback when init
  was missed (truncated streams). The legacy bare-init shape is
  intentionally rejected. This is the same bug that would have made
  Session caching corrupt on every cold turn.

- Same bug in execute_headless_task's permission-mode validation site:
  the check matched the wrong shape, so permission_mode_validated never
  flipped to True and the protective kill-on-misconfigured-permission
  path silently failed open. Now uses type=system + subtype=init.

- New persist_session flag threaded through ParallelTaskRequest →
  routers/chat.py → AgentRuntime ABC → ClaudeCodeRuntime.execute_headless
  → execute_headless_task. When True, --no-session-persistence is
  omitted so the JSONL is written and the next turn's --resume can find
  it. --session-id is still passed for unique cold-turn namespace.
  Default False keeps every existing caller stateless.

- gemini_runtime accepts the parameter for ABC parity and ignores it
  (Gemini CLI has no resume).

Backend:

- task_execution_service.execute_task now accepts persist_session: bool
  = False and threads it into the agent payload. All existing callers
  (Chat, schedules, MCP, fan-out, webhooks) keep today's behavior; only
  the future routers/sessions.py (Phase 2) opts in.

- settings_service.is_session_tab_enabled() — feature flag resolving
  system_settings.session_tab_enabled → SESSION_TAB_ENABLED env →
  False. Module-level convenience function exposed.

Tests (run inside trinity-backend container — Python 3.11):

- tests/unit/test_session_operations.py — 9 tests against an isolated
  SQLite DB exercising the full SessionOperations CRUD plus the cached
  claude session UUID lifecycle and resume failure / success counters.

- tests/unit/test_claude_code_session_id_parser.py — 8 tests covering
  both parsers (batch + streaming): system/init recognition, result
  fallback, init-wins-over-result, legacy bare-init rejection, and a
  source-level regression guard for the permission-mode validation
  fix.

- tests/unit/test_session_persistence_flag.py — 8 tests pinning the
  contract: signatures across the runtime ABC, ParallelTaskRequest,
  agent chat router, execute_headless_task, and
  task_execution_service.execute_task. Includes the gating regex check
  on --no-session-persistence and a live signature import to catch
  drift AST parsing alone would miss.

Total: 25 passing tests covering every touchpoint of Phase 1.

Base image (trinity-agent-base) rebuilt to embed the agent-server
changes; existing agent containers will pick them up on next recreate.

* feat(session-tab): backend turn endpoint for --resume-default Session surface

Phase 2 of docs/planning/SESSION_TAB_2026-04.md. Six endpoints under
/api/agents/{name}/session{s,...} that mirror routers/chat.py's auth
model and TaskExecutionService usage but persist to the parallel
agent_sessions / agent_session_messages tables and request
persist_session=True on every turn so each call reattaches via
`claude --print --resume <uuid>`.

Surface gated on is_session_tab_enabled() — flag-off default returns
404 from every endpoint.

  POST   /api/agents/{name}/session                  create row
  GET    /api/agents/{name}/sessions                 list (per-user)
  GET    /api/agents/{name}/sessions/{id}            session + messages
  POST   /api/agents/{name}/sessions/{id}/message    THE turn
  POST   /api/agents/{name}/sessions/{id}/reset      clear cached uuid
  DELETE /api/agents/{name}/sessions/{id}            delete row + msgs

Spike-pitfall defenses baked into the turn endpoint:

- L3 (first-turn-has-no-session-id): the agent_sessions row is created
  server-side via POST /session BEFORE the turn endpoint ever calls
  execute_task. No frontend-first model.
- L2 (cold turn writes empty JSONL): persist_session=True is passed
  unconditionally — Phase 1.4 already wired the flag through the agent
  stack; Phase 2 just promises to set it on every turn.
- L1 (parser misses system/init): trust result.session_id directly —
  Phase 1.3 fixed the parser. Scenario A confirms the captured UUID is
  the real Claude UUID end-to-end.

Phase 2.2 resume-failure fallback: when execute_task returns "no
conversation found" on a turn that had a cached UUID, clear the cache,
mark_resume_failure, and retry once with resume_session_id=None. Logs
event=session_resume_fallback with the stale UUID and consecutive
failure count. Anthropic #39667 (cleanupPeriodDays) and #53417 (CLI
upgrade) both produce this signal.

Phase 2.3 Redis lock: SET NX EX per (agent, claude_uuid) with 5-min TTL
and Lua-script release. Async poll loop (250ms tick) so the event loop
stays free during contention. Cold turns skip the lock (no JSONL to
corrupt). Hard 30s wait ceiling — beyond that the contender gets HTTP
429 with retry hint. Mitigation for Anthropic #20992 (concurrent
--resume JSONL writes corrupt the file).

Per-user ownership at the row layer: even agent owners cannot read or
send into another user's session (E6 isolation in the design doc).
Returns 404 for ownership failures so we don't leak session-id existence.

Tests (tests/integration/test_session_turns.py, run inside
trinity-backend container with docker.sock mounted for testfix
recreation + JSONL surgery in Scenario C):

  Scenario A: 3-turn happy path — same Claude UUID across turns
  Scenario B: turn 2 recalls a secret from turn 1, no text-replay
  Scenario C: JSONL deletion mid-session triggers fallback + recovery
  Scenario D: concurrent POSTs serialise via Redis lock
              (asserts finish_gap ≈ winner_work_time, NOT total wall)
  Scenario E: switching sessions A → B → A preserves A's UUID

5 passed in 54.5s against the live agent-testfix container (recreated
onto the rebuilt base image first per L4 in the plan). Phase 1's 25
unit tests still pass — no regressions.

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

* feat(session-tab): frontend Session surface

Phase 3 of docs/planning/SESSION_TAB_2026-04.md. Adds the new "Session"
tab in AgentDetail, gated on the is_session_tab_enabled() platform flag
so it stays invisible until explicit opt-in (default off).

Backend prerequisite — routers/settings.py:

- GET /api/settings/feature-flags exposes a curated allowlist of UI-
  relevant flags to any authed user. The existing /api/settings/{key}
  endpoint is admin-only and would block non-admin frontends from even
  knowing whether to render the Session tab. The new endpoint reads
  through services.settings_service.is_session_tab_enabled() so the
  resolution order (DB → env → False) stays in one place.

Frontend:

- src/frontend/src/stores/sessions.js — Pinia store wrapping the six
  /api/agents/{name}/sessions* endpoints with per-agent state isolation
  and the feature-flag cache. Optimistic user-message insert with
  rollback on send failure.

- src/frontend/src/components/SessionPanel.vue — structural copy of
  ChatPanel reusing ChatMessages + ChatInput + ModelSelector. Differs
  from Chat in three places per the design doc:
    * Sends bare user_message to POST .../sessions/{id}/message — no
      buildContextPrompt text-replay (the agent already has working
      memory via --resume).
    * "Reset memory" button + confirm modal that clears the cached
      Claude UUID without deleting the message log (Phase 3.4).
    * Per-session selector subtitle: turn count, context % used,
      cached-memory dot (emerald/gray), and consecutive_resume_failures
      indicator (Phase 3.5).
  Lean cut for first-visible-surface: voice mic, file upload, and SSE
  dynamic status labels are deferred — those need backend extensions
  (file payload on the turn endpoint, async_mode + SSE on the same).

- src/frontend/src/views/AgentDetail.vue — new Session tab inserted
  between Chat and Dashboard/Schedules, gated on
  sessionsStore.sessionTabEnabled. Layout sites that previously
  branched on activeTab === 'chat' now use a shared isFullscreenTab
  computed so Chat and Session both get the input-pinned-to-bottom flex
  layout. ?tab=session deep-link allowlist updated.

- src/frontend/e2e/session-tab.spec.js — Phase 3.6 Playwright spec.
  Marked @interactive (not @smoke) because each run makes one real
  Claude API call (~10–60s). Snapshots the prior flag value in
  beforeAll, force-enables for the run, restores in afterAll so a
  failed run doesn't leave the platform with the flag dirty. Three
  cases:
    * tab is hidden when flag is off
    * tab appears, "+ New Session" → send turn → reply visible →
      Reset memory modal opens + closes
    * Chat tab still works after Session interaction; switching back
      preserves Session state

Visually verified in the live dev server: tab renders in correct
position, header layout matches Chat's structure, empty state and
placeholder copy match the design doc, "Reset memory" only shown when
an active session exists, full-viewport flex layout pins input to
bottom.

Phase 1 + Phase 2 work behind this change is unchanged: 25 unit tests
+ 5 integration tests still green.

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

* feat(session-tab): hardening + observability — cleanup service, contamination gate, docs

Phase 4 of docs/planning/SESSION_TAB_2026-04.md. Closes the JSONL
disk-growth loop, validates the GA-blocking cross-session contamination
hypothesis empirically, and lands architecture.md / feature-flows
documentation so the surface is discoverable.

Phase 4.3 — cross-session contamination GA gate (the load-bearing one):

- tests/integration/test_session_cross_contamination.py exercises the
  Anthropic #26964 hypothesis end-to-end. Plants a randomly-generated
  secret token in session A with explicit "do not echo" framing, asks
  session B (different UUID, same agent, same cwd) to recall the token.
  Hard-fails if the exact token leaks; soft-fails on partial-prefix
  recall (PURPLE-DRAGON without the random suffix would only be
  knowable from A's JSONL, not from training).
- PASSED in 9.5s on the current Claude Code version → shared-cwd model
  is safe → Phase 5 rollout unblocked. Test stays in the suite as the
  per-version regression guard.

Phase 4.2 — JSONL cleanup service:

- services/session_cleanup_service.py runs a 6h periodic sweep that
  diffs every running agent's
  ~/.claude/projects/-home-developer/<uuid>.jsonl set against
  db.list_active_claude_session_ids(agent) and reaps orphans whose
  mtime is older than the 1h race guard. Race guard prevents the
  cold-turn-vs-cleanup window where a brand-new JSONL exists on disk
  before the backend has updated cached_claude_session_id.
- Same service exposes a synchronous reap_jsonl(agent, uuid) helper
  called best-effort from routers/sessions.py reset/delete handlers so
  the user-perceived disk-reclaim latency is sub-second. Never raises;
  failures are logged and the periodic sweep is the safety net.
- Implementation uses execute_command_in_container — the same primitive
  git_service / ssh_service / scheduler pre-check / agent terminal use.
  No new agent-server endpoint, no base-image rebuild.
- New db.list_active_claude_session_ids(agent) facade method backed by
  SessionOperations.list_active_claude_session_ids querying every
  agent_sessions row whose cached_claude_session_id is non-null for the
  agent.
- main.py wires startup (staggered +7.5s after cleanup_service to
  offset Docker hits) and clean shutdown.
- tests/integration/test_session_cleanup.py: reset reaps synchronously,
  delete reaps synchronously, periodic sweep keeps the active JSONL,
  reaps an aged orphan, respects the 1h race guard for fresh orphans.

Phase 4.4 — architecture.md updates:

- Background Services table gets a Session Cleanup row.
- New "Session Tab" subsection in API Endpoints documenting all six
  /api/agents/{name}/sessions* routes including the per-user ownership
  rule (404 not 403) and the resume-failure fallback / Redis lock.
- New /api/settings/feature-flags row.
- New agent_sessions / agent_session_messages DDL block in Database
  Schema, with the three Session-specific fields called out
  (cached_claude_session_id, consecutive_resume_failures,
  cache_read_tokens, claude_session_id audit).

Phase 4.5 — feature-flows/session-tab.md vertical slice:

- Full path from UI → API → DB → Side Effects with the JSONL lifecycle
  table, the spike-pitfall defense map (L1/L2/L3/#20992/#26964), the
  error-handling matrix, and the complete test catalog with the docker
  run command for the integration suite.
- feature-flows.md index updated (Recent Updates row + Chat & Sessions
  section entry).

Test totals: 25 unit + 9 integration = 34 tests, all green. Phase 4.3
serves as both the GA gate and the per-Claude-version regression guard.

Phase 4.1 (cache_read_tokens UI surfacing) deferred — the column is
already populated by the Phase 2 turn endpoint; surfacing is a minor
observability follow-up that doesn't block Phase 5.

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

* feat(session-tab): tag Session-tab turns with triggered_by="session" and a gold badge

Previously Session-tab turns went into schedule_executions with
triggered_by="chat", so the Tasks tab couldn't tell them apart from
the Chat tab. The user-visible signal was that every Session turn
showed up under the sky-blue "chat" badge.

Backend (routers/sessions.py): both call sites that invoke
task_execution_service.execute_task — the cold/resume turn and the
resume-failure fallback retry — now pass triggered_by="session".
Existing rows are unchanged; the cutover is per-write.

Frontend (TasksPanel.vue): adds a "Session" option between "Chat" and
"Manual" in the trigger filter dropdown, plus an amber/gold badge
branch (bg-amber-100 dark:bg-amber-900/30 text-amber-700
dark:text-amber-300) — visually distinct from "paid" (bright yellow)
and from the sky-blue "chat" badge.

triggered_by is a free-form TEXT column (no enum constraint at the DB
or service layer), so adding "session" as a new value doesn't require
any migration or downstream consumer updates. Filter, badge, audit
log, activity stream, and dashboards all just see another value and
display it; nothing has to know about it explicitly.

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

* fix(session-tab): correct context-window accounting + raise frontend turn timeout

Five interrelated fixes from manual testing — all about the per-turn
"context %" metric being misleading and the browser timing out before
long-running session turns finished.

1) Agent server (docker/base-image/agent_server/services/claude_code.py)
   process_stream_line's `result` event handler used to overwrite
   metadata.input_tokens, cache_read_tokens, and cache_creation_tokens
   with the values from result.usage. Those values are CUMULATIVE
   across every internal API call the turn made (Claude Code packs
   tool-use loops into a single user turn that maps to N internal API
   calls). For an 18-iteration turn each reading the same 70K cached
   prefix, result.usage.cache_read_input_tokens = 18 * 70K = 1.26M
   tokens — billing-cumulative, not the prompt size of any single call.
   Overwriting per-message values with that aggregate made our
   context-window-pressure metric grow far beyond the 200K limit even
   when no individual API call was anywhere close to the wall.

   Fix: result handler now only extracts model-level facts (cost,
   duration, num_turns, session_id, error info, modelUsage.contextWindow).
   Per-API-call usage stays in the per-assistant-message handler, where
   the LATEST message's values represent the FINAL API call's prompt
   size — exactly what determines whether the next turn will fit.

   Also added a per-message usage-extraction block to the assistant
   branch of process_stream_line (it previously had no usage extraction
   at all, relying entirely on the result handler — which made my
   first attempt at this fix produce zero values). parse_stream_json_output
   already had the equivalent block (lines 211-215).

   Base image rebuilt; agent-testfix recreated onto the new image
   (image sha 0a1e20b40da1).

2) Backend (services/task_execution_service.py)
   Replaced `context_used = metadata.input_tokens` with
   `cache_read + cache_creation` (with input_tokens fallback when
   caching isn't engaged). input_tokens is sometimes the disjoint
   fresh value and sometimes inflated by the agent server's
   modelUsage.inputTokens override on tool-call turns. cache_read
   and cache_creation come straight from Anthropic's usage object
   and (post agent-server fix) are reliable per-call values that
   monotonically reflect the cached conversation prefix.

3) DB (db/sessions.py)
   total_context_used is now a HIGH-WATERMARK (MAX of prior + new),
   not the latest value. Per-turn context naturally oscillates by ~2x
   between text-only and tool-call turns; the watermark gives users
   a stable monotonic upper bound on session pressure that only goes
   up.

   Capped the watermark at total_context_max as a safety belt against
   any future agent-server bug that emits cumulative-billing token
   counts. Genuine per-call peaks should never exceed the model's
   context window — if they do, that's an accounting error not a
   real overflow, and the UI should display 100% rather than 648%.

4) Frontend (stores/sessions.js)
   Bumped the Axios timeout on the session turn endpoint from 305s
   (~5 min) to 7260s (= TIMEOUT-001 cap of 7200s + 60s slack). The
   session turn endpoint is synchronous and may legitimately run for
   the agent's full execution timeout. With the previous 305s ceiling
   the browser threw a misleading "failed" toast on tool-heavy turns
   that ran longer; the response still landed in the DB and the UI
   recovered after a page refresh, but the user saw a phantom error.

Verified end-to-end with a 6-turn mixed sequence (text + tool-call):
per-call cache_read now reports ~11636 on text…
vybe added a commit that referenced this pull request Jun 5, 2026
…1081) (#1086)

Replace the push actor-model with pull / work-stealing in
TARGET_ARCHITECTURE.md, per the 2026-06-05 design review. The backend
owns one durable per-agent queue (schedule_executions); agents pull when
they have free capacity; capacity is physical (worker count); recovery is
lease-expiry re-delivery of the same execution_id.

- Governing principle #5, data layer (queue→Postgres, Redis loses the
  mailbox/slot-ZSET), agent runtime, and observability updated to match.
- Adds the side-effect idempotency contract (the hardest open problem,
  #1084) and the Postgres-before-the-queue sequencing constraint (#300).
- Reconciles the tracking table + open questions with the new issues:
  #1081 umbrella, #1082 status-as-projection, #1083 fire-and-forget,
  #1084 effect-idempotency, #1085 herd controls (all under Epic #1045);
  #946 reframed as the pilot, #307/#526 as gate→alert.
- Includes the team announcement record.

Co-authored-by: Eugene Vyborov <eugene@beingluminous.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
dolho added a commit that referenced this pull request Jun 19, 2026
OSS side of two-factor auth (enterprise issue #5). All IP (TOTP verify,
secret store, recovery codes, policy) lives in the private trinity-enterprise
submodule; this PR adds only the edition-agnostic seam + the entitlement-gated
Vue surface, consistent with the open-core pattern (#847).

Backend seam:
- services/mfa_gate.py — single hook the enterprise provider registers into.
  No provider (OSS-only build) → gate_login() returns None → login unchanged.
  Fail-open on provider error (never lock everyone out of login).
- dependencies.py — create_mfa_challenge_token / decode_mfa_challenge; a
  challenge-scoped token is rejected as a session token in get_current_user
  and decode_token.
- routers/auth.py — /token and /api/auth/email/verify consult mfa_gate after
  the first factor; when a second factor is required they return a short-lived
  challenge token instead of an access token (audited as mfa_challenge_issued).
- models.py — Token gains optional mfa_required / challenge_token fields.
- Dockerfile — pyotp (used by the enterprise module running in this image).

Frontend (gated by `2fa` in GET /api/settings/feature-flags):
- Settings → Security tab (TwoFactorPanel): self-service enroll/confirm/
  disable/recovery; admin-only org policy.
- Login.vue: second-factor step (verify + forced-enroll) after the password/
  email factor; QrCode.vue renders the otpauth URI (graceful manual-key
  fallback). auth store carries the challenge through to the real token.

OSS-only builds: no Security tab, no login step, /api/enterprise/2fa/* → 404.

Related to Abilityai/trinity-enterprise#5

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
vybe pushed a commit that referenced this pull request Jun 19, 2026
…ty-enterprise#5) (#1247)

* feat(auth): two-factor (TOTP) login seam + enterprise-gated UI

OSS side of two-factor auth (enterprise issue #5). All IP (TOTP verify,
secret store, recovery codes, policy) lives in the private trinity-enterprise
submodule; this PR adds only the edition-agnostic seam + the entitlement-gated
Vue surface, consistent with the open-core pattern (#847).

Backend seam:
- services/mfa_gate.py — single hook the enterprise provider registers into.
  No provider (OSS-only build) → gate_login() returns None → login unchanged.
  Fail-open on provider error (never lock everyone out of login).
- dependencies.py — create_mfa_challenge_token / decode_mfa_challenge; a
  challenge-scoped token is rejected as a session token in get_current_user
  and decode_token.
- routers/auth.py — /token and /api/auth/email/verify consult mfa_gate after
  the first factor; when a second factor is required they return a short-lived
  challenge token instead of an access token (audited as mfa_challenge_issued).
- models.py — Token gains optional mfa_required / challenge_token fields.
- Dockerfile — pyotp (used by the enterprise module running in this image).

Frontend (gated by `2fa` in GET /api/settings/feature-flags):
- Settings → Security tab (TwoFactorPanel): self-service enroll/confirm/
  disable/recovery; admin-only org policy.
- Login.vue: second-factor step (verify + forced-enroll) after the password/
  email factor; QrCode.vue renders the otpauth URI (graceful manual-key
  fallback). auth store carries the challenge through to the real token.

OSS-only builds: no Security tab, no login step, /api/enterprise/2fa/* → 404.

Related to Abilityai/trinity-enterprise#5

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

* fix(ci): regenerate package-lock for qrcode dep

frontend-build npm ci failed — package.json added qrcode@^1.5.4
but package-lock.json was not regenerated, leaving it out of sync.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
vybe pushed a commit that referenced this pull request Jun 23, 2026
…erts, SSH, binary) (#1305)

* feat(credentials): curated credential file-type injection — SA keys, certs, SSH, binary (enterprise#11)

Widens CRED-002 injection from the fixed 3-path exact allowlist
(.env/.credentials.enc/.mcp.json) to a curated set of credential file *types*,
without reopening the arbitrary-path RCE surface (#183/#590/#598).

- New services/credential_paths.py — single-source policy: ALLOW (.config/gcloud/**,
  .kube/config, *.pem/*.key/*.crt/*.cert/*.p12/*.pfx, .ssh/id_*, + existing exact set)
  with deny-precedence over anything executed/sourced at startup (shell rc,
  CLAUDE.md/AGENTS.md/.claude/**, .mcp.json.template, .ssh/authorized_keys/config,
  .git*, bin/**) and `..`/absolute traversal. Vendored byte-identically into the
  agent image (Invariant #5) with a parity test.
- Agent-server hardening: the inject + update file loops now enforce the policy AND
  a resolve-under-home traversal guard the original write path lacked; parent-dir
  creation + chmod 0o600 preserved. New GET /api/credentials/list for export discovery.
- Binary-safe: inject carries files_b64 (base64); agent writes via write_bytes.
  .credentials.enc gains a v2 {files, files_b64} envelope (legacy flat archives still
  decrypt); encrypt/decrypt stay flat for the single-secret callers (SIEM/2FA/SSO).
- Export now captures the FULL injected set (via /list) + binary, not just the 2 defaults.
- Three surfaces in sync (Invariant #13): MCP inject_credentials gains files_b64;
  frontend CredentialsPanel gains a file-upload affordance (text vs base64 auto-detected).
- Tests: allowlist test now exercises the REAL policy (newly-allowed + still-blocked),
  + credential_paths parity test + binary archive round-trip test. 61 pass.
- docs/memory/architecture.md: credential-path policy documented.

Related to Abilityai/trinity-enterprise#11. Loosens a deliberately-tight boundary —
run /cso on the diff before merge.

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

* fix(credentials): close /cso findings on the injection widening (#11 review)

Security review of the widening surfaced one HIGH regression + hardening items;
all fixed here.

#1 (HIGH, RCE): the #598 .mcp.json content-validation guard was bypassable via
the new files_b64 (binary) channel — validate_mcp_config only checked `files`,
so `files_b64={".mcp.json": base64(<stdio-command MCP server>)}` skipped it and
configured an RCE MCP server on the target agent. Fix: .mcp.json may only arrive
as TEXT (files), where it is validated; rejected in files_b64 at the backend
inject router AND the agent-server write helper.

#2 (defense-in-depth): import/auto-import wrote decrypted archives via the
agent-server /inject layer only. Added validate_credential_set() (curated path
policy + .mcp.json content + no-binary-.mcp.json) on the backend import boundary
so enforcement is dual-layer as the issue mandates. (Archives are AES-GCM with
the server key, so a forged archive wasn't practical — but the layer belongs.)

#3: .ssh/ is now locked to id_* only — a stray *.key/*.pem under .ssh is no
longer accepted (policy was previously broader than the "SSH keys = id_*" intent).

#4 (noted): .config/gcloud/** can hold a google-auth executable credential_source;
only honored under non-default GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES=1.
Documented in credential_paths.py.

+6 regression tests (169 pass). CSO report: docs/security-reports/cso-2026-06-22-11-diff.md.

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

* fix(credentials): exclude vendor dirs from cert globs + accurate export count (#11 live test)

Found while testing PR #1305 against a real local instance:

1. Over-capture: the broad *.pem/*.key/*.crt globs matched bundled CA files
   (e.g. .local/.../site-packages/certifi/cacert.pem), so export's /list walk
   swept vendored cert material into .credentials.enc. Added node_modules,
   site-packages, .local, .venv/venv, .cache, go/pkg to the deny-list (both
   root and nested forms) so cert globs only catch real credential files.

2. export's files_exported count re-read just the 2 default files (reported 1
   while the archive actually held 5). export_to_agent now returns the true
   captured count; dropped the redundant stale read.

Verified end-to-end on a live agent: allowed types inject (text+binary, 0600,
parent dirs), blocked paths 400 (incl. .ssh non-id_*, .mcp.json-via-files_b64,
weaponized .mcp.json text), and binary round-trips through export→import with
matching sha256. +5 regression tests (72 pass).

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
vybe pushed a commit that referenced this pull request Jul 6, 2026
…1445) (#1453)

* fix(webhooks): gate schedule/webhook creation on a live agent (#1445)

Root cause: PR #1433 gave get_schedule_by_webhook_token an INNER JOIN on
agent_ownership, so a schedule whose agent has no live ownership row now
404s at token lookup. can_user_access_agent returns True unconditionally
for admins with no existence check, so admin callers could mint orphan
schedules (with real webhook tokens) on never-created agents — tokens that
then 404 deterministically. This is the source of the "intermittent under
load" webhook 404s: a test-fixture artifact, not concurrency.

Fix at the creation boundary, fail-loud, keeping #1433's INNER JOIN intact:

- New db.is_agent_live(name) predicate: live agent_ownership row,
  deleted_at IS NULL, no users join — matches the token-lookup predicate
  exactly (get_agent_owner joins users → false-negative on a live agent
  with a missing owner row).
- routers/schedules.create_schedule: access-check FIRST (uniform 403, no
  404-vs-403 enumeration oracle), then is_agent_live 404, before the #929
  timeout check.
- routers/schedules.generate_webhook: is_agent_live 404 after the access
  check (defense-in-depth, not a pre-auth probe).
- db/schedules.create_schedule: chokepoint guard (is_agent_live → None →
  403) so the no-orphan invariant holds for every caller.
- webhooks.py: static, neutral per-branch 404 log lines (no token
  interpolation) so the next occurrence is grep-attributable.

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

* test(webhooks): harden webhook tests off orphan agents; gate regression (#1445)

- Move the WHOLE module off the name-only `test_agent_name` fixture (which
  creates no ownership row) onto a module-scoped `webhook_agent` fixture
  that POSTs a real agent (live ownership row, no flaky running-wait,
  mirrors the #1423 throwaway-agent pattern). Post-gate, `_create_test_schedule`
  asserts 201, so every test — generation AND trigger — needs a real agent.
- New TestWebhookCreationGate: creating a schedule on a never-created agent
  now 404s (was 201 → orphan); generate_webhook on a soft-deleted agent 404s.
- Fix the direct route-handler unit tests (test_929) to stub the new
  db.can_user_access_agent / db.is_agent_live gate so they reach the
  timeout-enforcement path under test.

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

* docs(webhooks): document the live-agent creation gate (#1445)

- architecture.md WEBHOOK-001: creation gate note (is_agent_live, 404/403
  ordering, chokepoint) — idempotency sentence untouched (#1437's territory)
- requirements/public-access.md §15.1g: creation-gated-on-live-agent bullet
- feature-flows/webhook-triggers.md: management-endpoint auth + creation-gate
  paragraph; failure-mode table notes (malformed/lookup-miss log lines,
  orphan schedules no longer creatable)
- feature-flows.md index: Recent Updates row
- user-docs/api-reference/webhook-triggers.md: add the missing WEBHOOK-001
  schedule-webhook endpoints + the public creation precondition

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

* test(webhooks): unit-pin the #1445 access-first ordering + no-orphan 404

Two importlib route-handler tests (no live stack):
- access-check-first => uniform 403 with is_agent_live never consulted
  (enumeration-oracle guard, plan Decision #5)
- authorised caller on a dead agent => 404, db.create_schedule never reached

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

* docs(webhooks): sync flow doc — is_agent_live gate + 404-branch logs (#1445)

/sync-feature-flows pass: DB Methods table gains the is_agent_live gate
predicate and spells out the #1423 soft-delete-aware token-lookup INNER JOIN;
the public-trigger steps note the static per-branch 404 log lines.

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

* test(webhooks): DB-layer #1445 gate unit tests; fix flow-index issue ref

Add tests/unit/test_1445_schedule_creation_gate.py — the backend-agnostic
(db_harness) coverage the router/integration tests can't reach:
is_agent_live (no users-join, matches the token-lookup predicate; live agent
with a missing owner-user row still reads live) and the create_schedule
chokepoint (no-orphan for MCP / system-manifest-deploy / any non-router caller).

Fix the feature-flows index row to cite #1423 (the soft-delete token-lookup
issue) instead of #1433 (its PR) — matches the code docstring, architecture.md,
requirements, and the flow doc.

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

---------

Co-authored-by: Andrii Pasternak <anpast31@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Eugene Vyborov <eugene@beingluminous.com>
vybe pushed a commit that referenced this pull request Jul 7, 2026
…+ confirm-re-read (#1450) (#1495)

* test(canary): unblock canary_invariants fixture broken by #1472 merge

Two pre-existing breakages landed via #1472 (367a5e1, merged today) in
root-level tests/test_canary_invariants.py — a file the CI unit job
(tests/unit/ only) does not run, so they merged undetected:

1. Duplicate `CREATE TABLE agent_schedules` in the `canary_db` fixture DDL
   — the E-06 work added a second definition next to the pre-existing one,
   so `executescript` raised "table agent_schedules already exists" and
   every fixture-dependent canary test errored. Removed the older, unused
   block (its `message`/`owner_id` columns have no consumer; `_add_schedule`
   and the E-06/L-03 collectors use only the kept block's columns).
2. `test_run_invariants_all` expected registry set omitted "E-06" (added to
   the invariants registry by the same PR). Added E-06 to the expected set
   and asserted it is green on a clean platform.

Restores a green baseline (92 passed) so the #1450 B-01 work can be verified.

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

* fix(canary): B-01 queue-status coherence — backend-consistent Side B + confirm-re-read (#1450)

B-01 compared two reads that were neither temporally atomic nor
backend-consistent (both latent while the canary is default-OFF and SQLite
makes the two reads hit one file):

(a) Temporal non-atomicity — a concurrent enqueue/backlog-drain landing
    between the two reads produced a transient count mismatch → spurious
    critical + green→red Slack alert.
(b) Backend divergence (#300/#1093) — Side A (`db.get_queued_count`) honors
    `get_engine()`/DATABASE_URL; Side B read raw sqlite3 at DB_PATH. On
    Postgres those are two different databases, so B-01 compared Postgres
    truth to a stale/absent SQLite file (fatal under the Postgres direction +
    SQLite EOL, #1278).

Production-side residue of #1446 (PR #1452), which fixed only the test-harness
`sys.modules` leak and deferred these two gaps.

Fix (localized to B-01):
- New `_collect_queued_ids_via_engine` reads B-01's Side B through the SAME
  `get_engine()` seam as the accessor, on a dedicated `queued_ids_via_engine`
  snapshot field. Independent code path (SELECT id/literal 'queued' vs
  COUNT(*)/the QUEUED enum) — shares a database, not a code path, so a
  cache/status-filter regression still surfaces (non-tautology, AC #3). No
  cache / second count of the queue (AC #4).
- The collector performs one confirm-re-read on a mismatch: a transient race
  self-heals; a persistent drift survives and fires (AC #2). An engine-read or
  unconfirmable confirm degrades to a B-01 skip — it never compares an engine
  count against the raw-sqlite id-set (the blocker the reviews surfaced).
- `queued_exec_ids` (raw sqlite) stays untouched for B-02/E-02, so blast
  radius is B-01-only and the sibling #1077 merge stays clean.

The running-side / known-agents reads remain raw-sqlite (half-migrated
collector); the collector-wide migration + a dark-canary tripwire are a filed
follow-up.

Tests (test_canary_invariants.py): retarget the synthetic B-01 tests at
`queued_ids_via_engine`; add the AC #5 regression net — a diverged-backend
proxy (raw ≠ engine temp files) that false-fires pre-fix and is green
post-fix, a transient-race confirm-absorb + persistent-drift-still-fires pair,
and an engine-read-failure→skip test. New split fixtures (`canary_db_split` /
`reload_canary_split`) model the diverged backend without a live PG. Convert
the file to the sanctioned `_STUBBED_MODULE_NAMES` + `_restore_sys_modules`
escape hatch so its import-time stubs no longer leak (the #1446 mechanism) and
the reimport `del`s are lint-clean.

Docs: architecture.md Canary B-01 row updated for the engine-backed read path
+ confirm-re-read.

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

* docs(feature-flows): add #1450 canary B-01 Recent Updates row

Sync-feature-flows: canary-internal change, no dedicated flow doc
(architecture.md is canonical); append one Recent Updates row.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Eugene Vyborov <eugene@beingluminous.com>
vybe added a commit that referenced this pull request Jul 8, 2026
…nc model catalog (#1521) (#1524)

Replace ~15 scattered hardcoded 200000 context-window denominators with one
model→context-window catalog so the "% context used" gauge is correct for
1M-context models (Opus 4.8/4.7, Sonnet 5, Fable 5, Gemini = 1M; Codex = 272K).
The runtime-reported modelUsage.contextWindow stays PRIMARY; the catalog is the
FALLBACK only — bare Claude → 200K safe floor, [1m] → 1M, Gemini → 1M,
Codex → 272K, unknown → 200K + logged warning. The bare-Claude floor is
deliberate: the effective window is auth/plan-dependent, so a fixed 1M fallback
would hide an imminent 200K compaction wall.

- New pure-stdlib services/model_context.py, vendored byte-identically into the
  agent server (Invariant #5, parity-tested).
- Unify 5 disagreeing agent-server resolvers (state.py, claude_code, gemini,
  codex, the ABC) via the catalog. Seed metadata.context_window at construction
  in the Claude + headless paths — the real Claude fallback, since
  get_context_window is dead code for Claude and the flat Pydantic default was
  the actual fallback. Fix state.py init-ordering so a [1m] agent reports 1M
  before its first turn. The non-abstract ABC default means a new runtime can't
  ship a silently-wrong 200K.
- Backend downstream fallbacks use the shared DEFAULT_CONTEXT_WINDOW constant.
- Re-sync ModelSelector presets (add Fable 5, Sonnet 5) + agent-server alias note.
- No schema change (columns/DDL unchanged; only written values become correct).

Co-authored-by: Eugene Vyborov <eugene@beingluminous.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Oleksii Dolhov <oleksii.dolhov@gmail.com>
vybe added a commit that referenced this pull request Jul 8, 2026
* fix(agent): make agent /tmp tmpfs size configurable via AGENT_TMP_SIZE (#1231) (#1233)

Agent containers mounted /tmp as a hardcoded 100 MB noexec,nosuid RAM-backed
tmpfs. It fills easily — e.g. `gh` CLI install artifacts (~38 MB) that hardcode
/tmp and bypass the #1098 TMPDIR redirect — after which every /tmp write fails
with "No space left on device", including git's commit scratch, so autonomous
scheduled runs "complete" but silently fail to persist. The size being a
literal meant operators couldn't tune it without a code change + base-image
rebuild.

- capabilities.py: AGENT_TMPFS_MOUNT size now read from AGENT_TMP_SIZE (env on
  the backend service, which builds the agent mount spec), default 512m,
  validated `^\d+[mg]$` with empty/invalid → default. noexec,nosuid stay
  hardcoded — only size is configurable, and it stays bounded (counts against
  the agent memory cgroup). Single source of truth, so create (crud.py) and
  recreate (lifecycle.py) can't drift.
- Wire AGENT_TMP_SIZE=${AGENT_TMP_SIZE:-512m} on the backend service in both
  docker-compose.yml and docker-compose.prod.yml; document in .env.example.
- architecture.md Container Security: note the now-configurable size.
- tests/unit/test_1231_agent_tmp_size.py: default, valid m/g, case-fold,
  invalid→default, and the security flags are never dropped.

Mount specs are creation-time: existing agents pick up a new size on recreate,
not restart. Builds on #1098 (TMPDIR redirect) — closes the gap for tools that
hardcode /tmp. The agent-side git-sync silent-persist-failure is a separate
issue in the abilities repo, per the ticket.

Related to #1231

Co-authored-by: Eugene Vyborov <1073874+vybe@users.noreply.github.com>

* feat(ui): per-schedule performance scorecards on Agent Detail (#1115) (#1149)

Surface per-schedule performance on the Overview tab and the Schedules tab,
both from a SINGLE compact aggregate (no N per-schedule round-trips) — extends
#1107 (Overview) and generalises #868 (per-schedule deep analytics).

Backend:
- db `get_agent_schedules_summary(agent, hours)` — one rollup row per
  non-deleted schedule (zero-run schedules included): terminal success_rate
  (success / (success + failed[incl. error]); None when zero terminal),
  NULL-skipping avg_duration_ms, cost_total, context_avg, tool_call_total
  (parsed over newest 5,000 rows agent-wide, tool_calls_sampled flag), and
  last-run outcome. Cheap grouped SQL; iso_cutoff window (Invariant #16).
- GET /api/agents/{name}/schedules/analytics-summary?window=7d|14d|30d
  (AuthorizedAgent). Declared BEFORE /{schedule_id} in routers/schedules.py
  so the literal segment isn't captured as a schedule_id (Invariant #4) —
  putting it in analytics.py would be shadowed (schedules_router mounts first).
- models: ScheduleSummaryRow + AgentSchedulesSummaryResponse (Invariant #14).

Frontend (single fetch, two consumers — Invariant #7):
- executions.js fetchSchedulesSummary, cached per ${name}:${window} like
  fetchAgentAnalytics.
- OverviewPanel: "Schedules performance" section, honors the existing 7/14/30d
  window selector, each row deep-links to the Schedules tab; hidden at zero.
- SchedulesPanel: inline mini-stats per row (success rate, avg duration, runs,
  last-run dot) — badge style, no new chart/modal — from the same call.

Tests: tests/unit/test_1115_schedules_summary.py (6) — terminal success rate,
NULL-skip avg, tool-call total, zero-run-still-appears, soft-delete excluded,
out-of-window excluded. Full analytics suites green (30 passed). Frontend
prod build clean; endpoint verified live across 7/14/30d windows.

Related to #1115

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Eugene Vyborov <1073874+vybe@users.noreply.github.com>

* feat(ui): in-app bug reporting from the floating Help widget (#1116) (#1283)

* docs(readme): document the Trinity Ops Agent and PostgreSQL backend/migration (#1290)

Adds a top-of-README callout recommending PostgreSQL for production (SQLite remains the zero-config dev default, opt-in via DATABASE_URL, #300), links the public Trinity Ops Agent (trinity-ops-public) for instance operations, and documents migrating existing SQLite instances via its /migrate-to-postgres skill. Also adds a Database section, a DATABASE_URL env row, and an ops-agent entry in the docs index.

Co-authored-by: Eugene Vyborov <eugene@beingluminous.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(reliability): unify the SUB-003 auth-class failure classifier into one shared module (#1088) (#1297)

The `is_auth_failure` + `AUTH_INDICATORS` + `NON_AUTH_KILL_MARKERS` (#904)
logic was duplicated inline in `subscription_auto_switch.py` and
`scheduler/service.py`, kept in sync by a hand-written "keep these lists in
sync" comment — exactly how the #904 kill-marker bug class re-appears.

Consolidate into one canonical module:

- New `src/backend/services/failure_classifier.py` — canonical, pure-stdlib
  classifier (55 lines). `subscription_auto_switch.py` now re-exports
  `is_auth_failure` unchanged, so existing importers
  (`routers/chat.py`, `services/task_execution_service.py`) and their test
  patch targets keep working.
- New `src/scheduler/failure_classifier.py` — byte-identical vendored mirror.
  The scheduler runs in a separate container and cannot import
  `backend.services`; it uses the classifier for log-labelling only (picks the
  `logger.error` wording, never gates a switch). The agent-runtime classifier
  in `error_classifier` is intentionally NOT merged — it diverges semantically
  and stays kill-safe by `_classify_signal_exit` precedence (D4).
- Byte-identity is enforced by
  `tests/unit/test_904_sigkill_no_false_auth.py::TestBackendSchedulerParity`;
  the re-export is pinned by `TestBackendReExportGuard`. No hand-sync.

Pure structural refactor, no behavioral change (verified by SHA-256 equality
of the two copies and line-for-line comparison vs the deleted code). The test
rewrite also drops the prior `exec(compile(...))` source-slicing in favour of
`importlib` path-loading, removing the only injection primitive in scope.

Tests: 19/19 pass in `test_904_sigkill_no_false_auth.py`.
CSO --diff: CLEAR (docs/security-reports/cso-diff-2026-06-21.md).

Refs #1088

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

* feat(orchestration): pull-pilot routing for agent→agent MCP chat behind default-OFF flag (#946) (#1293)

* feat(orchestration): pull-pilot routing for agent→agent MCP chat behind default-OFF flag (#946)

Phase 2 PoC for pull/work-stealing (Epic #1045, umbrella #1081). When
MCP_AGENT_CHAT_PULL_ENABLED is ON, a sequential agent→agent (scope='agent',
non-self) chat_with_agent is routed by the MCP server through the durable async
/task path instead of the synchronous held /chat; the caller gets an immediate
{accepted|queued, execution_id} receipt and polls get_execution_result.
scope='user', self-tasks, and parallel=true are unchanged. Default OFF — flag
flip + MCP routing revert is the whole rollback.

- config.py: canonical MCP_AGENT_CHAT_PULL_ENABLED registry entry (both services
  read the SAME env key, so a single-.env deploy can't drift).
- settings.py: surface mcp_agent_chat_pull_enabled in /api/settings/feature-flags
  (auth-gated, observability-only — not a UI surface).
- chat.py: release the idempotency claim on the two /task dispatch-breaker-open
  (CircuitOpen) deny paths, mirroring /chat and CapacityFull (T5 fix) — without
  it a breaker-open reject silently blocks same-key retries for 24h.
- mcp-server: scope-based pull routing + D8 dispatch-mode idempotency token so a
  flag flip can't replay a wrong-shape snapshot across endpoints; startup log of
  the routing mode for the soak's control/treatment window.
- tests: chat.test.ts (routing fork + key behavior), test_946_task_idempotency_on_deny.py
  (deny-path claim release), feature-flag exposure tests.
- docs: ACTOR_MODEL_POSTCARD (#945 resolved), PULL_PILOT_946_SOAK harness +
  go/no-go record, CSO diff audit (CLEAR), TARGET_ARCHITECTURE/architecture updates.

Refs #946

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

* docs(feature-flows): document pull-pilot routing (#946) + AGENT_TMP_SIZE tmpfs (#1231)

Sync feature-flow docs with recent changes:
- agent-to-agent-collaboration.md: new Pull-Pilot Routing (#946) section —
  flag-gated MCP routing fork to the durable async /task path, poll-for-result
  receipt contract, D8 idempotency route token, feature-flag exposure, and the
  T5 /task dispatch-breaker-open deny-path claim release.
- container-capabilities.md: refresh stale tmpfs facts — agent /tmp size is now
  operator-configurable via AGENT_TMP_SIZE (default 512m, noexec,nosuid fixed),
  plus the TMPDIR=/home/developer/.tmp heavy-scratch redirect (#1098).
- feature-flows.md: add Recent Updates index rows for #946 and #1231.

Refs #946

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Eugene Vyborov <eugene@beingluminous.com>

* feat(agent): runtime data_paths with portable export/import (#1169) (#1294)

* feat(agent): runtime data_paths with portable export/import (#1169)

Declare an agent's runtime data (SQLite DBs, datasets) under data/ on the
already-durable home volume — no separate volume, no platform schema change.

- template.yaml `data_paths:` surfaced by template_service (github + local)
  and materialized at creation by crud.py -> git_service.materialize_data_paths:
  writes ~/.trinity/data-paths.yaml and appends data/ + entries to the agent's
  own .gitignore (idempotent). Opt-in; empty list is a no-op.
- S4 persistent-state and data_paths now share one extracted heredoc/list
  primitive (materialize_trinity_yaml_list / _read_trinity_yaml_list).
- New routers/agent_data.py: POST /data/export (stream | base64, 413 over cap,
  manifest-only tar when data/ missing) and POST /data/import (proxies to the
  agent-server restore primitive; data/** allowlist + traversal guard;
  Idempotency-Key). Both serialized per agent by a cross-worker Redis op lock.
- MCP tools export_agent_data / import_agent_data (Invariant #13).
- Validation checks DP-001..DP-005 in agent-validation-spec.
- Docs: architecture, requirements, feature-flows index + agent-data-volumes
  flow, agent guide; CSO diff audit report (0 critical/high).

Tests: ~30 unit + TestClient tests (export/import endpoints, allowlist,
gitignore, template surface). PR2 (scheduled snapshots + pre-snapshot
quiesce hook + retention/cascade) deferred.

Closes #1169

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

* test(agent-data): satisfy sys.modules pollution lint in #1169 tests

The two new data_paths test files copied the baselined `patch.dict` +
bare `del sys.modules[...]` loader from test_persistent_state_allowlist.py,
which the sys.modules pollution lint flags as NEW (non-baselined) violations.

Adopt the blessed snapshot/restore exception (precedent:
test_telegram_webhook_backfill.py): declare a top-level
`_STUBBED_MODULE_NAMES` list + an autouse `_restore_sys_modules` fixture, and
install the stubs / evict the cached module directly (the fixture owns
restoration). Removes the bare `del sys.modules[...]` entirely rather than
hiding it. Drop the now-unused `patch` import from the gitignore test.

Lint passes (no new violations); both files' 16 tests still green; no
cross-file leakage.

Refs #1169

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

* docs(feature-flows): sync recent changes — #1231 tmpfs, #1115/#1231 index rows

Fix container-capabilities.md for the now-configurable agent /tmp tmpfs
(#1231): default 100m → 512m via AGENT_TMP_SIZE, and correct the stale
full_capabilities ternary excerpts to the shared AGENT_TMPFS_MOUNT constant
(noexec,nosuid always applied, both modes). Add Recent Updates index rows for
the per-schedule performance scorecards (#1115) and the tmpfs-size fix (#1231);
the #1169 and #1116 rows already shipped in-commit.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Eugene Vyborov <eugene@beingluminous.com>

* fix(security): authenticate the in-container agent server on the shared agent network (#1159) (#1292)

* fix(security): authenticate the in-container agent server on the shared agent network (#1159)

The in-container agent server (:8000) had zero inbound auth on
trinity-agent-network: any agent could read a sibling's .env secrets or
run arbitrary Claude on it. Every backend->agent call now carries a
per-agent X-Trinity-Agent-Token = HMAC-SHA256(AGENT_AUTH_SECRET,
"trinity-agent-auth:v1:"+name), verified by a pure-ASGI middleware on all
HTTP and WebSocket routes (constant-time compare; only /health exempt).

- Derive-don't-store: the master AGENT_AUTH_SECRET lives only in the
  backend env (auto-generated by start.sh like SECRET_KEY); each container
  receives only its own token, so a compromised agent cannot compute a
  sibling's. Fail-closed -- derive raises on an empty secret.
- Callers route through services/agent_auth.py (agent_httpx_client /
  build_agent_auth_headers / merge_auth_headers); a static guard test
  fails any new raw agent-{name}:8000 caller that bypasses them.
- Removed the dead, unauthenticated /ws/chat route (ran arbitrary Claude)
  and the agent server's wildcard CORS (internal-only).
- Grace path for old images: empty TRINITY_AGENT_AUTH_TOKEN -> allow;
  check_agent_auth_token_env_matches forces a one-pass recreate to inject.
- Retired the unused src/scheduler/agent_client.py.

Tests: unit (matcher, middleware, header guard, derivation) + security
isolation test. CSO diff audit in docs/security-reports/cso-diff-2026-06-20.md.

Closes #1159

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

* docs(feature-flows): document agent-server authentication (#1159) + sync recent flows

Add a feature-flow doc for the in-container agent-server inbound auth shipped
in this PR, and sync the index with two recent merged changes.

- New feature-flows/agent-server-authentication.md: end-to-end trace of the
  derived X-Trinity-Agent-Token (HMAC over AGENT_AUTH_SECRET), the pure-ASGI
  middleware enforcing it on every HTTP/WS route, fail-closed vs grace path,
  recreate reconciliation, and the migrated callers + static guard. Added to
  the Authentication & Security catalog in the index.
- container-capabilities.md: refresh the stale /tmp tmpfs size (was a hardcoded
  100m) to the configurable AGENT_TMP_SIZE (default 512m, #1231).
- Recent Updates rows for #1159, #1231, and the previously-missing #1115.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Eugene Vyborov <eugene@beingluminous.com>

* docs(voip): genericize moved-issue reference in feature-flow

#1039 (configurable data-retention) moved to the private enterprise
tracker; replace the now-private issue number in voip-telephony.md with a
generic description so the public doc doesn't deep-link a private issue.

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

* feat(agents): server-side compatibility validation with auto-fix (#668)

Run ~100 best-practice checks (11 categories) against a running agent's
workspace, surfaced non-blocking in the Overview tab with one-click
auto-fix for the 10 gitignore checks, plus an MCP tool.

- services/compatibility/ package (spec/collector/static_checks/ai_checks/fixes):
  ONE docker exec -> in-container python -> JSON snapshot (secret files
  existence-only, size/binary caps); pure STATIC checks (HARD-only) +
  category-batched AI checks (Haiku, iterate-expected, fail-open, capped at
  SOFT, secret-redacted); runtime-aware (claude-only checks skipped for
  Codex/Gemini).
- GET/POST endpoints (read AuthorizedAgentByName; fix OwnedAgentByName, gitignore
  only, per-agent Redis lock, atomic write, uncommitted until next sync;
  include_ai path rate-limited). agent_compatibility_results table (dual-track
  SQLite + Alembic) persists the latest snapshot; cascade/rename via AGENT_REFS.
- CompatibilityPanel.vue (two-phase fetch, grouped checklist, per-check fix,
  re-run) in OverviewPanel; get_agent_compatibility_report MCP tool.
- 35 fixture-driven unit tests; spec sync-tested against docs/agent-validation-spec.md.

Persistence departs from the issue's "no DB table" note so AI verdicts show
without re-spend + enable fleet aggregation (see requirements section 41).

Fixes #668

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

* fix(compat): remove polynomial-ReDoS in secret-assignment regex (#668)

CodeQL py/polynomial-redos (high): `_ASSIGN_RE` captured the value as
`[ \t]*(.+?)[ \t]*$`. The lazy `.+?` and the surrounding `[ \t]*` can both
match a tab, giving polynomial backtracking when `_redact()` runs the pattern
over up to 48 KB of agent-supplied file text.

Capture the value greedily to end-of-line (`(.*)$`) and let the callers
strip — both `_looks_placeholder()` callers already `.strip()`, so secret
detection and redaction are behaviourally identical (verified: `=`, `:`,
`export`, and indented forms still match). 35 unit tests pass.

* feat(sso): OSS gated surface for enterprise SSO (OIDC) (#32)

Companion to trinity-enterprise#36. OSS carries only the entitlement-gated
surface; all SSO logic lives in the private submodule.

- Login.vue: "Sign in with <IdP>" buttons (shown only when the `sso` feature is
  entitled and a provider is enabled), plus OIDC callback-fragment handling
  (`/login#sso=ok|mfa|error`) — reuses the existing 2FA challenge UI when the
  IdP login still requires a local second factor.
- stores/auth.js: completeSsoLogin() (reuses _finalizeLogin / _setMfaChallenge)
  + fetchSsoProviders() (empty in OSS-only builds — endpoint 404s).
- Settings.vue: admin-gated "SSO" tab → SsoPanel.vue (provider CRUD + test +
  policy). Gated by enterpriseStore.isEntitled('sso'), same as the 2FA tab.
- Bump enterprise submodule to the SSO module commit.
- docs: architecture enterprise-modules row + requirements §40 (SSO/OIDC).

No new backend dependency (python-jose + httpx already in the image) and no
OSS Python changes — the mint/whitelist/mfa seams already exist.

Stacked on feat/5-2fa-totp (reuses the OSS mfa_gate + 2FA challenge surface).

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

* chore(sso): bump enterprise submodule to OIDC hardening (#32 review)

Pulls in the email_verified / issuer-pinning / login-CSRF fixes
(trinity-enterprise 87c8f97). OSS gated surface unchanged.

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

* docs(planning): note incubating goal-directed direction; reword voip flow

Add an "Incubating Directions (Not Yet Decided)" section to
TARGET_ARCHITECTURE.md capturing the goal-directed control-surface idea
(Objective + policies + roster + externally-measured evals), explicitly
bounded by CLAUDE.md §8 and sequenced after the pull migration + #300.
Incubating in trinity-enterprise#27.

Reword the voip-telephony flow note to drop a stale #1039 reference in
favor of describing the LOG_* data-retention no-op class directly.

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

* docs(security): add CSO 2026-06-21 posture report

Routine /cso full-audit posture report (Phases 0–14, daily 8/10 gate).
Follows the docs/security-reports/ convention; no real secrets reproduced.

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

* docs(user-docs): video library + per-page links, v0.6.1 What's New, sync dev features

- Add videos.md (35 published videos, newest-first by topic) and a README Watch section
- Add 'Watch' callouts to 32 feature pages linking the most relevant, newest videos
- Add user-facing whats-new/v0.6.1.md (translated from release notes; no issue numbers)
- Document dev-only features: agent runtimes (Claude Code/Codex/Gemini CLI, #1187),
  agent data paths + export/import (#1169), compatibility validation (#668),
  in-app bug reporting (#1116), subscription hot-reload (#1089), pull-pilot routing (#946),
  configurable AGENT_TMP_SIZE (#1231), Postgres migration-runner groundwork (#1160)
- Index agent-runtimes, agent-data, and the previously-orphaned agent-session pages

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

* feat(setup): first-run operator intake + admin email login (abilityai/trinity-enterprise#38, #82)

Capture an optional operator email/company at first-run setup with an explicit,
unchecked-by-default opt-in to "occasionally receive important security & product
updates", submitted once to a new /v1/operator-intake endpoint on #1116's
Cloudflare intake app. The same email binds as the admin's sign-in identity so
the operator can log in with email + password — no verification email is sent (a
fresh install has no Resend key; the email is bound, not code-verified). The
code-based email second factor stays Phase 2 on the existing mfa_gate seam.

- backend: operator_intake_service (fire-and-forget, at-most-once via a
  system_settings marker, DO_NOT_TRACK aware, owns installation_id); setup
  endpoint captures profile + binds admin email; authenticate_user resolves the
  admin by username OR registered email (password guard blocks code-only users);
  PUT /api/users/me/email for the existing-admin transition
- frontend: SetupPassword email/company + consent checkbox; Login "username or
  email" field; Settings -> General "Admin sign-in email" card
- config: OPERATOR_INTAKE_ENABLED / OPERATOR_INTAKE_URL (+ .env.example)
- docs: requirements section 43, architecture catalog, first-time-setup feature flow
- tests: 16 unit tests (intake idempotency/guards, email-login resolution, setup)

Fixes abilityai/trinity-enterprise#38
Fixes #82

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

* docs(feature-flows): index row for first-run intake + admin email login (trinity-enterprise#38, #82)

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

* fix(setup): de-ambiguate email regex to clear CodeQL polynomial-ReDoS (#82)

The _EMAIL_RE pattern duplicated into setup.py and users.py had two
[^@\s]+ atoms around the literal \. that both also match '.', giving the
engine many ways to place the dot and backtracking polynomially on
user-controlled email input (CodeQL alerts #211, #212).

Constrain only the final segment to [^@\s.]+ (no dot) so the trailing \.
can align with exactly one position -> linear matching. Behaviour is
unchanged: multi-subdomain addresses still validate; an 80k-char
pathological input now resolves in ~2ms.

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

* feat(credentials): curated credential file-type injection (SA keys, certs, SSH, binary) (#1305)

* feat(credentials): curated credential file-type injection — SA keys, certs, SSH, binary (enterprise#11)

Widens CRED-002 injection from the fixed 3-path exact allowlist
(.env/.credentials.enc/.mcp.json) to a curated set of credential file *types*,
without reopening the arbitrary-path RCE surface (#183/#590/#598).

- New services/credential_paths.py — single-source policy: ALLOW (.config/gcloud/**,
  .kube/config, *.pem/*.key/*.crt/*.cert/*.p12/*.pfx, .ssh/id_*, + existing exact set)
  with deny-precedence over anything executed/sourced at startup (shell rc,
  CLAUDE.md/AGENTS.md/.claude/**, .mcp.json.template, .ssh/authorized_keys/config,
  .git*, bin/**) and `..`/absolute traversal. Vendored byte-identically into the
  agent image (Invariant #5) with a parity test.
- Agent-server hardening: the inject + update file loops now enforce the policy AND
  a resolve-under-home traversal guard the original write path lacked; parent-dir
  creation + chmod 0o600 preserved. New GET /api/credentials/list for export discovery.
- Binary-safe: inject carries files_b64 (base64); agent writes via write_bytes.
  .credentials.enc gains a v2 {files, files_b64} envelope (legacy flat archives still
  decrypt); encrypt/decrypt stay flat for the single-secret callers (SIEM/2FA/SSO).
- Export now captures the FULL injected set (via /list) + binary, not just the 2 defaults.
- Three surfaces in sync (Invariant #13): MCP inject_credentials gains files_b64;
  frontend CredentialsPanel gains a file-upload affordance (text vs base64 auto-detected).
- Tests: allowlist test now exercises the REAL policy (newly-allowed + still-blocked),
  + credential_paths parity test + binary archive round-trip test. 61 pass.
- docs/memory/architecture.md: credential-path policy documented.

Related to Abilityai/trinity-enterprise#11. Loosens a deliberately-tight boundary —
run /cso on the diff before merge.

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

* fix(credentials): close /cso findings on the injection widening (#11 review)

Security review of the widening surfaced one HIGH regression + hardening items;
all fixed here.

#1 (HIGH, RCE): the #598 .mcp.json content-validation guard was bypassable via
the new files_b64 (binary) channel — validate_mcp_config only checked `files`,
so `files_b64={".mcp.json": base64(<stdio-command MCP server>)}` skipped it and
configured an RCE MCP server on the target agent. Fix: .mcp.json may only arrive
as TEXT (files), where it is validated; rejected in files_b64 at the backend
inject router AND the agent-server write helper.

#2 (defense-in-depth): import/auto-import wrote decrypted archives via the
agent-server /inject layer only. Added validate_credential_set() (curated path
policy + .mcp.json content + no-binary-.mcp.json) on the backend import boundary
so enforcement is dual-layer as the issue mandates. (Archives are AES-GCM with
the server key, so a forged archive wasn't practical — but the layer belongs.)

#3: .ssh/ is now locked to id_* only — a stray *.key/*.pem under .ssh is no
longer accepted (policy was previously broader than the "SSH keys = id_*" intent).

#4 (noted): .config/gcloud/** can hold a google-auth executable credential_source;
only honored under non-default GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES=1.
Documented in credential_paths.py.

+6 regression tests (169 pass). CSO report: docs/security-reports/cso-2026-06-22-11-diff.md.

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

* fix(credentials): exclude vendor dirs from cert globs + accurate export count (#11 live test)

Found while testing PR #1305 against a real local instance:

1. Over-capture: the broad *.pem/*.key/*.crt globs matched bundled CA files
   (e.g. .local/.../site-packages/certifi/cacert.pem), so export's /list walk
   swept vendored cert material into .credentials.enc. Added node_modules,
   site-packages, .local, .venv/venv, .cache, go/pkg to the deny-list (both
   root and nested forms) so cert globs only catch real credential files.

2. export's files_exported count re-read just the 2 default files (reported 1
   while the archive actually held 5). export_to_agent now returns the true
   captured count; dropped the redundant stale read.

Verified end-to-end on a live agent: allowed types inject (text+binary, 0600,
parent dirs), blocked paths 400 (incl. .ssh non-id_*, .mcp.json-via-files_b64,
weaponized .mcp.json text), and binary round-trips through export→import with
matching sha256. +5 regression tests (72 pass).

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

---------

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

* docs(security): annotate CSO 2026-06-21 findings as remediated; drop stale voip hunk

Two review-driven fixes ahead of the v0.7.0 cut:

- Annotate the CSO posture report (.md + .json) with post-audit remediation
  status. The report audited `main` pre-cut and listed F1/F2/F3 as open
  VERIFIED findings; they are already remediated on `dev` and ship in v0.7.0:
    - F1 (unauth agent-server) -> #1159 X-Trinity-Agent-Token middleware
    - F2 (fastmcp -> hono/undici) -> #1255, #1289; fastmcp ^4.3.0
    - F3 (form-data CRLF via axios) -> #1254
    - F4/F5/F8 exploit path closed by #1159 (auth gate)
  Adds a top-of-report banner, per-row status tags, per-finding notes, and a
  machine-readable `remediation_status` block in the JSON. Avoids publishing a
  stale "open CRITICAL + exploit" to a PUBLIC repo without its fix context.

- Drop the voip-telephony.md reword: it is superseded by already-merged #1301,
  which made the identical `#1039` -> `LOG_*` change on `dev`. Restoring to
  merge-base removes the redundant/conflicting hunk from this PR.

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

* chore: set version to 0.7.0

* feat: streamline first-time setup wizard (abilityai/trinity-enterprise#49)

Drop the log-copied setup token, require an admin email, and rebuild the
first-run page as a welcoming animated welcome screen.

Backend (routers/setup.py, main.py):
- Remove the setup-token machinery entirely (ensure_setup_token /
  clear_setup_token / Redis-shared token + the main.py startup emission).
  Setup no longer depends on Redis — the admin write goes straight to SQLite.
- Make admin email REQUIRED (sign-in identity): missing -> 422 at the model
  layer; blank/typo -> 400, validated before any write so setup never
  half-completes. Password complexity (OWASP ASVS 2.1) still enforced.
- get_setup_status keeps setup_available:true for frontend back-compat.

Frontend (SetupPassword.vue):
- Full redesign: dark branded hero with an animated orbiting fleet
  constellation (Trinity mark core + agent nodes on three rings), split
  layout (stacks on mobile), prefers-reduced-motion aware.
- No setup-token field; email required; order email -> password (+confirm)
  -> company -> updates opt-in. Removed the Redis-wait panel + polling.

Security tradeoff (chosen: accept + document): removing the token leaves the
unauthenticated first-run window with no proof-of-control. Documented as an
operator responsibility (deploy behind a tunnel/VPN until setup completes) in
docs/DEPLOYMENT.md Security Recommendations; endpoint still self-disables
after first success. See docs/security-reports/cso-diff-2026-06-23.md (F1).

Docs: DEPLOYMENT.md security note, architecture.md, requirements.md
(§15.2/§43), feature-flows/first-time-setup.md.

Tests: remove obsolete test_1165_setup_token_shared.py; update test_setup.py
(no token, email required) and test_setup_operator_profile.py (email
required, model-layer + blank/invalid rejection). 7 operator-profile unit
tests pass; new contract verified live (422/400 negative paths).

Fixes abilityai/trinity-enterprise#49

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

* fix(setup): full-bleed setup screen via normal flow, not position:fixed

The redesigned first-run page used `position:fixed; inset:0` for its root.
On wider viewports this left a band of the light `#app` (bg-gray-100)
background showing through on the right/bottom — a fixed root is clipped to
the nearest transformed/contained ancestor instead of the viewport, so its
coverage isn't guaranteed.

Switch the root to the original component's proven normal-flow approach
(`position:relative; width:100%; min-height:100vh`), which fills the
full-width `#app`, and make the decorative aurora/grid `position:absolute`
within it. Verified covering the full viewport at 2560x1440 (light mode, the
repro case) and stacking correctly at 430px.

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

* fix(tests): defer routers.setup import so unit collection can't be corrupted

The CI backend-unit regression gate runs `cd tests && pytest unit/` (the whole
unit suite). test_setup_operator_profile.py imported `routers.setup` at module
(collection) time; that import — pulling in database/dependencies/services and
their many `utils.*` leaves — failed/perturbed sys.modules during collection and
INTERRUPTED the entire `unit/` collection (head collected ~2 of 2734 → the diff
gate flagged it as a new failure).

Defer the `import routers.setup` to a cached `_get_setup()` accessor used inside
the tests, so module collection imports only stdlib/pytest/fastapi/pydantic and
can never corrupt the suite. `_get_setup()` also spec-preloads the backend
`utils.*` leaves (helpers/errors/credential_sanitizer/password_validation/
url_validation/image_optimize) the same way conftest preloads `utils.helpers`,
without touching `sys.modules["utils"]`, so the import resolves cleanly at run
time regardless of harness utils state.

Verified with the exact CI command (`cd tests && pytest unit/ --co`): the full
suite now collects 2734 items with no interruption, and the 7 setup tests pass.
No conftest changes.

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

* fix(tests): drop sys.modules preload — plain lazy import (passes #762 lint)

The previous commit's `_get_setup()` spec-preloaded backend utils leaves via
`sys.modules[...] = …` / `.pop`, which tests/lint_sys_modules.py (#762) bans
outside conftest. It's also unnecessary: in the backend-unit gate
(`cd tests && pytest unit/`), tests/unit/conftest.py already installs
src/backend/utils as the canonical `utils` package, so a plain lazy
`import routers.setup` resolves the backend `utils.*` leaves natively.

Simplify `_get_setup()` to a cached plain lazy import — no sys.modules
mutation. Verified: lint clean (no new violations), `pytest unit/ --co`
collects 2734 with no interruption, and the 7 setup tests pass.

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

* fix(tests): update #858 guards for setup-token removal (#49)

Removing the setup token (trinity-enterprise#49) deleted
`routers/setup.py::ensure_setup_token` and the lifespan token emission, so two
#858 regression guards asserted gone behavior and failed in the backend-unit
gate:
  - test_ensure_setup_token_logs_token_via_logger_warning
  - test_lifespan_emits_setup_token_via_logger_before_event_bus

The #858 invariant itself is intact: the lifespan still emits the first-run
notice via `logger.warning` (not print), after setup_logging() and before
event_bus.start(). Replace the token-specific guard with one that matches the
new FIRST-TIME SETUP warning by content + ordering, drop the now-obsolete
ensure_setup_token guard, and remove the unused BACKEND_SETUP constant. The
Dockerfile PYTHONUNBUFFERED parity checks and the no-print-in-lifespan guard are
unchanged. 4 passed.

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

* feat(whatsapp): deliver ChannelResponse.files as Twilio MediaUrl (#1315)

WhatsApp agents can now send files to users. send_response delivers
ChannelResponse.files as Twilio MediaUrl attachments (one message per file,
text first), reaching parity with the Slack adapter.

- New create_share_from_bytes() persists in-memory bytes through the FILES-001
  pipeline (MIME-blocklist/quota/disk/DB) and mints a public ?sig= URL; both it
  and create_share now share the extracted _persist_and_register helper.
- Per-agent file_sharing_enabled gate; 1h share TTL (cleanup reaper purges).
- Caps (image/audio/video ~5MB, documents ~16MB) on the detected MIME; graceful
  text-link fallback when public_chat_url is unset/non-HTTPS, the MIME is
  unsupported, or the file is oversized — never silently dropped.
- Per-file isolation: a rejected/failed file never aborts the text or siblings.
- 42 unit tests; requirements.md + whatsapp-integration.md updated.

Fixes #1315

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

* fix(whatsapp): webhook routes to backend, not frontend (#1281) (#1316)

The WhatsApp panel's deployment-prerequisite notice told operators to route
/api/whatsapp/webhook/* to the "frontend service". That path is a backend
FastAPI route (Twilio HMAC-verified); pointing tunnel ingress at the static
SPA silently drops inbound messages. Corrected to the backend service
(http://backend:8000), matching the cited PUBLIC_EXTERNAL_ACCESS_SETUP.md.

Related to #1281

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

* feat(ui): loading skeletons for Dashboard graph & timeline (#1266) (#1312)

The initial fleet/metrics load can take 20s+ on 10+ agent fleets (#1265);
until now the Dashboard rendered the "No agents" empty state (or blank
timeline) during that wait, so the UI looked frozen/broken.

- New reusable `SkeletonLoader.vue` (dark-mode aware, accessible
  role=status/aria-busy, reserves space to avoid layout shift) with `rows`
  (timeline/list) and `nodes` (collaboration graph) variants.
- `stores/network.js`: add `loading` (defaults true so the first paint is a
  skeleton, not the empty state) + `loadError` (distinct failed-load state),
  toggled in `fetchAgents` (finally-cleared so a failure never shows an
  infinite skeleton).
- `Dashboard.vue`: graph canvas and timeline now render skeleton → error →
  empty → content off those flags. Loading shows immediately on nav; error
  states offer a Retry (reuses `refreshAll`).

Frontend-only; pairs with the backend perf work in #1265. `vite build` passes.

Related to #1266

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

* docs(db): set SQLite end-of-support to September 1, 2026 + Postgres migration notes (#1278) (#1314)

Records the firm SQLite end-of-support date and the SQLite → PostgreSQL
migration announcement/guidance. Documentation/decision only — SQLite code
removal stays with the migration work (#300/#1183/#746).

- docs/migrations/SQLITE_TO_POSTGRES.md (new): authoritative guide — EOL date,
  what changes and when, switching a fresh deployment (DATABASE_URL + postgres
  profile), migrating an existing deployment (backup-first; no turnkey data-copy
  tool yet — honest cutover options), verification, and release-notes copy.
- docs/releases/v0.6.2.md (new, draft): EOL announcement section linking the
  guide, seeding the next release notes.
- docs/planning/TARGET_ARCHITECTURE.md + docs/memory/architecture.md (Invariant #3):
  reference the EOL date so it's discoverable outside the release.
- Cross-links the in-repo reminder companion (#1279).

Related to #1278

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

* docs: stop disclosing enterprise functionality in public docs (trinity-enterprise#45) (#1311)

* docs: stop disclosing enterprise functionality in public docs (trinity-enterprise#45)

The public repo documented the full design, feature catalog, and gating
strategy of the paid enterprise tier — a free blueprint of what we monetize
and how it's built. This removes that competitive content and keeps only the
generic open-core seam public.

- Delete 4 strategy/design docs (OSS_ENTERPRISE_SPLIT_RESEARCH,
  ENTERPRISE_ARCHITECTURE, feature-flows/enterprise-modules, ENTERPRISE_LOCAL_DEV)
- architecture.md "Enterprise Modules" table -> neutral seam pointer
  (no paid-feature catalog, no enterprise_* table DDL, no per-module detail)
- requirements.md §35 -> abstract EntitlementService seam (drop the enumerated
  module list + dead links to the deleted strategy docs)
- audit-trail.md: neutralize the lone enterprise-pillar mention
- CLAUDE.md: standing rule — enterprise designs live only in trinity-enterprise
- CI: enterprise-docs-guard.yml fails the build if live public docs reintroduce
  paid-feature / private-schema tokens

Content is preserved (relocated to the private trinity-enterprise repo, see the
companion PR). Git-history scrub of the deleted files + point-in-time historical
docs (archive/, releases/, security-reports/) tracked as a follow-up.

Related to Abilityai/trinity-enterprise#45

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

* ci(enterprise-docs-guard): add least-privilege permissions block

Clears CodeQL actions/missing-workflow-permissions (medium). The guard only
checks out and greps, so contents: read is sufficient.

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

---------

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

* docs(releases): add 0.7.0 release notes

* fix(#1115): port get_agent_schedules_summary to SQLAlchemy Core (Postgres-safe)

The #300 SQLAlchemy migration dropped the get_db_connection import from
db/schedules.py but left get_agent_schedules_summary (#1115) calling it,
so the /schedules/analytics-summary endpoint raised NameError at runtime.
Surfaced for the first time by the v0.7.0 release-PR full-suite run (dev
pushes only lint).

Port the method to get_engine() Core queries like its siblings, and
replace the SQLite-only bare-column-with-MAX last-run query with a
portable ROW_NUMBER() window so it works on PostgreSQL too.

Also refresh the test_login_rate_limit_split config stub, which went
stale when auth.py grew a PUBLIC_ACCESS_REQUESTS_ENABLED dependency
(trinity-enterprise#10) — 8 collection errors under HEAD.

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

* feat(voip): per-agent VoIP config panel + persisted voice (abilityai/trinity-enterprise#28)

Add the missing per-agent VoIP config UI (agent Settings/Sharing tab) and a
persisted per-agent Gemini voice. Shipped as plain OSS gated on the existing
voip_available platform flag — NOT entitlement-gated (a UI gate over a
money-spending OSS backend would be cosmetic; deliberate simplification of the
issue's original "entitlement-gated" framing).

Backend:
- agent_ownership.voice_name (default Kore) via dual-track migration
  (SQLite db/migrations.py + Alembic 0004 + schema.py/tables.py). db
  get/set_voice_name with read-path fallback to Kore for unset/invalid values.
- GET/PUT /api/agents/{name}/voice/name (PUT owner-only, validated against
  GEMINI_VOICE_NAMES). _get_voice_name and voip_service now read the persisted
  voice instead of the two hardcoded "Kore" sites.
- PUT /api/agents/{name}/voip/enabled toggle (owner-only, 404 when no binding);
  create_binding upsert no longer forces enabled=1 so re-saving credentials
  preserves a disabled state (call path already refuses disabled bindings).

Frontend:
- VoipChannelPanel.vue (modeled on WhatsAppChannelPanel) mounted in SharingPanel
  under voip_available; shared src/constants/voices.js (drift-guarded vs backend);
  AgentWorkspace picker defaults to the persisted voice; sessions store surfaces
  voip_available.

Tests: tests/unit/test_28_voip_voice_config.py — voice fallback/roundtrip/
invalid->default, enable toggle + re-PUT-preserves-disabled (H3), and the
frontend/backend voice-list drift guard. Schema-parity + voip-db guards green.

Refs abilityai/trinity-enterprise#28

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

* test(voip): HTTP-level endpoint tests for /voice/name + /voip/enabled (#28 review I1)

Pre-landing /review flagged that the new endpoints were covered only at the DB
layer. Add FastAPI TestClient tests (mount real routers, override auth deps, stub
db/voip_service) asserting:
- PUT /voice/name: owner-gated (403), 400 on unknown voice, empty clears to
  default, valid voice persists; GET returns voice_name + available_voices.
- PUT /voip/enabled: owner-gated (403), 404 when no binding / when voip flag off,
  200 reflecting state with no auth_token leaked.

Also capture a durable learning (docs/memory/learnings.md): the schema-parity
test is blind to db/tables.py drift — a missing Column there passes parity but
breaks at runtime; guard it with a db-accessor unit test that executes a live
select on the new column.

Refs abilityai/trinity-enterprise#28

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

* refactor(docs): agent-readable zero-to-value onboarding (#1280) (#1336)

* refactor(docs): agent-readable zero-to-value onboarding (#1280)

Make the repo's entry points machine-first so an autonomous agent can self-orient and reach a useful result without a human translating context.

- AGENTS.md: add a "Using this file" machine-contract header (declares it the authoritative agent entry point, states the AGENTS/CLAUDE/README boundary, explains how to traverse). Rebuild Route-by-task with an explicit "Done when" zero-to-value signal per persona.
- CLAUDE.md: cross-link to AGENTS.md and frame CLAUDE.md as the contributor working agreement (auto-loaded by Claude Code), not the agent landing page.
- Fixes from a context-free agent onboarding test (AC#5 validation): AGENTS.md deploy verify no longer assumes an undefined $TOKEN (leads with `trinity agents list`, shows token derivation); deploy section states the running-instance prerequisite; README CLI example adds the `trinity agents list` verify step; docs/CLI.md leads with `pip install trinity-cli` (PyPI) and marks `-e src/cli/` as the from-source/dev variant.

Validated by an agent performing a zero-to-value deploy task using only repo files, no human context: self-oriented in 2 hops (README -> AGENTS.md) to a correct deploy+verify answer; friction items above are its findings, folded back in.

Repo-root AGENTS.md is hand-authored; the CLAUDE.md->AGENTS.md mirror (#1187) is per-agent-container (startup.sh), so these edits are conflict-free.

Related to #1280

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

* docs(templates): machine-readable starter-template catalog (#1280)

Affordance sweep for agent self-selection. Previously an agent had to `ls`
config/agent-templates/ (24 dirs, 7 of them test fixtures) and open each
template.yaml to find a starting point.

- config/agent-templates/README.md: catalog grouping the 17 real templates
  (single-purpose: scout/sage/scribe/demo-*/trinity-system; the dd-* due-
  diligence suite) with one-line affordances, how-to-use, and an explicit
  "not starting points" list for the test/canary fixtures
- AGENTS.md: link the catalog from the Deploy-an-agent section so the
  zero-to-value path is "pick a ready-made template", not "author from scratch"

Related to #1280

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

---------

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

* fix(tests): un-quarantine and fix the 15 unmasked unit failures (#1103) (#1338)

Removes the @pytest.mark.skip quarantines added in #300 and fixes the
underlying issues. Each group fixed at root cause, not by matching assertions
to current behavior.

Environmental (git identity):
- tests/unit/conftest.py: set GIT_AUTHOR/COMMITTER_NAME/EMAIL process-wide so
  in-test `git commit` works on a CI runner with no global git identity
  ("Author identity unknown"). Fixes test_reset_preserve_state_guardrails (3)
  and the test_git_pull_branch end-to-end setups.

Test-setup bug (production code was correct):
- test_git_pull_branch.py: the repos did `git push -u origin main` but `git init`
  defaults to `master` (no init.defaultBranch), so origin/main never existed and
  _get_pull_branch correctly fell back to the working branch — the assertions
  expecting "main" failed. Force `git init -b main` (local + bare). Fixes all 5
  (TestGetPullBranch 2 + TestGitPullFromMainEndToEnd 3).

Test-isolation bug (assertions were correct):
- test_orphaned_execution_recovery.py: shared module-level mocks were reset with
  plain reset_mock(), which keeps return_value/side_effect — so one test's
  get_agent_container.side_effect bled into later tests under random ordering,
  skewing recovery counts ("assert 3 == 2"). Reset with
  reset_mock(return_value=True, side_effect=True). Stable across 5 seeds.

Real lint findings:
- docker/base-image/startup.sh: shellcheck now exits 0. Converted the 4 fragile
  file-iteration loops to `find -print0 | while read` (SC2010/SC2045/SC2044),
  hardened 8 `cd` with `|| exit 1` (SC2164), split the SC2155 export, and
  documented-disabled SC2001 on the two regex `sed` lines that ${//} can't
  express. `bash -n` clean. Un-skips test_startup_sh_shellcheck_clean.

backlog (3) and 929 (1) were already un-quarantined on dev (db_harness schema),
so no change needed there.

Verified: the 11 un-skipped tests pass across multiple random seeds; full unit
suite shows no regressions from these changes (the unrelated pre-existing
test_1115_schedules_summary / test_admin_email_login failures fail on dev too).

Related to #1103

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

* chore(deps-dev): bump happy-dom (#1326)

Bumps the patch-and-minor group in /tests/git-sync with 1 update: [happy-dom](https://github.com/capricorn86/happy-dom).


Updates `happy-dom` from 20.10.5 to 20.10.6
- [Release notes](https://github.com/capricorn86/happy-dom/releases)
- [Commits](https://github.com/capricorn86/happy-dom/compare/v20.10.5...v20.10.6)

---
updated-dependencies:
- dependency-name: happy-dom
  dependency-version: 20.10.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: patch-and-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps-dev): bump @types/node in /src/mcp-server (#1327)

Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 25.9.3 to 26.0.0.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 26.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump fastmcp (#1325)

Bumps the patch-and-minor group in /src/mcp-server with 1 update: [fastmcp](https://github.com/punkpeye/fastmcp).


Updates `fastmcp` from 4.3.0 to 4.3.2
- [Release notes](https://github.com/punkpeye/fastmcp/releases)
- [Commits](https://github.com/punkpeye/fastmcp/compare/v4.3.0...v4.3.2)

---
updated-dependencies:
- dependency-name: fastmcp
  dependency-version: 4.3.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: patch-and-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump the patch-and-minor group (#1329)

Bumps the patch-and-minor group in /src/frontend with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [axios](https://github.com/axios/axios) | `1.18.0` | `1.18.1` |
| [@playwright/test](https://github.com/microsoft/playwright) | `1.61.0` | `1.61.1` |
| [autoprefixer](https://github.com/postcss/autoprefixer) | `10.5.0` | `10.5.1` |
| [@rollup/rollup-darwin-arm64](https://github.com/rollup/rollup) | `4.62.0` | `4.62.2` |
| [@rollup/rollup-linux-arm64-musl](https://github.com/rollup/rollup) | `4.62.0` | `4.62.2` |


Updates `axios` from 1.18.0 to 1.18.1
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.18.0...v1.18.1)

Updates `@playwright/test` from 1.61.0 to 1.61.1
- [Release notes](https://github.com/microsoft/playwright/releases)
- [Commits](https://github.com/microsoft/playwright/compare/v1.61.0...v1.61.1)

Updates `autoprefixer` from 10.5.0 to 10.5.1
- [Release notes](https://github.com/postcss/autoprefixer/releases)
- [Changelog](https://github.com/postcss/autoprefixer/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/autoprefixer/compare/10.5.0...10.5.1)

Updates `@rollup/rollup-darwin-arm64` from 4.62.0 to 4.62.2
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.62.0...v4.62.2)

Updates `@rollup/rollup-linux-arm64-musl` from 4.62.0 to 4.62.2
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.62.0...v4.62.2)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.18.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: patch-and-minor
- dependency-name: "@playwright/test"
  dependency-version: 1.61.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: patch-and-minor
- dependency-name: autoprefixer
  dependency-version: 10.5.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: patch-and-minor
- dependency-name: "@rollup/rollup-darwin-arm64"
  dependency-version: 4.62.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: patch-and-minor
- dependency-name: "@rollup/rollup-linux-arm64-musl"
  dependency-version: 4.62.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: patch-and-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* feat(executions): propagate cancelled terminal status end-to-end (#679) (#1333)

Defense-in-depth follow-up to #671. Make the agent task-runner aware that an
operator cancel happened and surface a third terminal outcome — `cancelled` —
alongside success/failed, so a cancel is never recorded as a billable success
or an agent failure.

Agent server:
- ProcessRegistry records a `_terminated[execution_id]` marker on a successful
  SIGINT send; `was_terminated()` (read-only, 300s lazy TTL, cleared on
  register) lets the sync chat handler and async result callback relabel a
  graceful-exit-0 / SIGKILL->504 turn as cancelled.
- `record_task_finish` accepts a neutral finish (success=None): a cancel
  neither resets nor increments the dispatch-breaker failure counter (#526).

Backend:
- 3-way status map (success->SUCCESS, cancelled->CANCELLED, else->FAILED) in
  the async callback (routers/agents.py) and the sync applier
  (task_execution_service). An auth/rate terminal is never reclassified as a
  cancellation — guarded at the backend trust boundary too (CSO finding 2).
- Consumers (message_router, chat, paid, public, validation_service) treat
  cancelled as non-delivery; paid no longer settles on cancel (money bug).
- terminate writes CANCELLED only when it actually stopped a running turn; on
  already-finished the agent's real terminal stands (Issue 7).

Tests: 9 new unit suites (64 cases) + execution-termination integration
additions; 85 unit tests pass locally. CSO diff audit: CLEAR.

Refs #679

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

* feat(ui): unify Chat + Session into one Chat tab with a session-mode toggle (#1112) (#1340)

Collapse the redundant Chat + Session tabs on Agent Detail into a single "Chat"
tab carrying a "Session mode" toggle (default ON), keeping the legacy stateless
surface as a first-class user-selectable mode rather than dead code.

- AgentDetail.vue: single `{ id: 'chat' }` tab (drop the separate Session entry).
  New `chatMode` ref ('session'|'legacy', default 'session') persisted per-user in
  localStorage['trinity.chatMode']. `sessionAvailable` = feature flag on AND
  runtime has --resume (not Codex); `effectiveChatMode` forces legacy when the
  Session surface is unavailable and hides the toggle. The toggle swaps
  SessionPanel ↔ ChatPanel in-place (v-if). isFullscreenTab keys on the single
  'chat' id; `?tab=session` aliases to 'chat' (hinting session mode).
- Execution-resume: ExecutionDetail "continue as chat" (?tab=chat&resumeSessionId)
  forces legacy ChatPanel (which owns resume) via a transient, non-persisted
  routeForcedMode — without rewriting the user's saved preference.
- No backend change (session_tab_enabled already exists). MobileAdmin unaffected:
  its openChat is a self-contained mobile chat overlay, not an AgentDetail
  deep-link, so there is nothing to repoint.
- docs: architecture Session Tab block + requirements §5.8 note.

Related to #1112

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

* feat(access): Access tab — manage Trinity operators per agent (trinity-enterprise#17) (#1317)

New Access tab on Agent Detail that manages Trinity operators (platform users)
with access to an agent, distinct from the Sharing tab's external channel
clients. Draws the operator-vs-client line on the read path.

Backend:
- db.get_agent_operator_access(): outer-joins agent_sharing × users on the
  grantee email (lower-cased, engine-based → PG+SQLite). Resolved → active
  operator (username/role/last_active); unresolved → pending invite.
- GET /api/agents/{name}/access (AgentOperatorAccess model). Add/remove reuse
  the existing /share + /share/{email}.

Frontend:
- AccessPanel.vue: operator roster (status + role badges, last-active), add by
  email, remove. Access tab wired into AgentDetail (owner-gated).
- SharingPanel.vue: Team Sharing allow-list removed (moves to Access); dead
  share-management script + stale "Team Sharing below" copy cleaned/repointed.
- stores/agents.js: getAgentAccess().

Tests: active-vs-pending classification + agent scoping. vite build passes.

Note: the strict client-vs-operator split (non-user emails → a dedicated client
roster) is deferred to the Sharing-side redesign (#18/#20); removing them here
now would orphan those grants, so all allow-list entries stay visible on Access.

Related to Abilityai/trinity-enterprise#17

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

* fix(ui): slim the Overview executions-by-type bars

The "Executions by type" stacked bars rendered full-width with a 1px
gap, so a busy agent's week read as a solid wall of color. Cap each
bar at 56px and center it inside its (still full-width) hover column,
and soften the top corner. The column stays flex-1 so spacing/tooltips
are unchanged and wider windows (14d/30d) thin naturally.

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

* feat(voip): add Gacrux to the Gemini Live voice picker

Adds the "Gacrux — Mature" prebuilt voice to the per-agent VoIP voice
selector (and the shared AgentWorkspace per-session picker, which reads
the same list). Updated in lockstep across the three mirrored sources so
the frontend↔backend parity test stays green:

- src/frontend/src/constants/voices.js — single frontend source of truth
- src/backend/config.py GEMINI_VOICE_NAMES — write-validation allowlist +
  read-path fallback
- tests/unit/test_28_voip_voice_config.py — hardcoded parity tuple

Follow-up to #1323 (per-agent VoIP config panel + persisted voice).

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

* feat(ui): reframe Sharing tab to external-client sharing via channels (#1347)

Part of the Access/Sharing redesign (Epic trinity-enterprise#16). The Access
tab (#17) already owns Trinity operators; this scopes the Sharing tab to the
operator → external-client surface.

- Google-Docs-style "Share this agent" framing; operator language removed
  (operators live on the Access tab).
- External access policy collapsed into one **Restricted ↔ Open** segmented
  control over require_email/open_access (Restricted = approval-gated, Open =
  anyone verified; identity proof always on for external sharing).
- Pending requests kept, reframed as external clients awaiting approval.
- Channels rendered as compact collapsible summary rows (new
  ChannelDisclosure.vue). Detailed config stays reachable inside the expanded
  row as a non-regressing interim seam — #19 replaces the body with a modal
  dialog. No channel functionality removed.
- Outbound file sharing + public links nudged into a separate "Distribution"
  section (distribution, not client access).

Frontend-only; no API changes. SharingPanel prop/emit contract unchanged.
Verified: both SFCs compile (vue/compiler-sfc), design-token check passes.

Related to abilityai/trinity-enterprise#18

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

* docs(architecture): trim architecture.md under the 150k-char context limit (#1344)

architecture.md had grown to ~156.7k chars (on dev), past the 150k
soft limit Claude Code warns about when auto-loading it each session
(it's `@`-imported by CLAUDE.md). Over the limit the file risks silent
truncation and eats a large slice of the context window every session.

Compressed the densest Cross-Cutting Subsystem narratives, the migration
and non-root-container invariants (#3/#17), a few catalog/endpoint rows,
and the longest frontend UI prose — preferring summary + pointer where a
dedicated `feature-flows/` doc already owns the deep detail (the doc's own
editorial rule). Result: 156.7k → 149.3k chars (~4.8% smaller).

No facts dropped: every issue tag, field name, default, and error-string
is preserved; protected SQLite DDL (tracked by /validate-schema) untouched;
heading/code-fence/table counts unchanged; all 12 added flow-doc links
resolve.

Related to the over-limit warning surfaced in Claude Code.

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

* chore(deps): bump js-yaml from 4.2.0 to 5.1.0 in /src/frontend (#1330)

Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.2.0 to 5.1.0.
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.2.0...5.1.0)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 5.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* fix(ci): guard the Alembic (Postgres) track in schema-parity (#1342) (#1345)

* fix(ci): guard the Alembic (Postgres) track in schema-parity (#1342)

The schema-parity required check validated only the SQLite track
(migrations.py ↔ schema.py). A schema change that ships the SQLite
migration but omits the Alembic revision under
src/backend/migrations/versions/ passed every required check green yet
broke PostgreSQL — init_database() runs alembic_runner.upgrade_to_head(),
which applies revision files only and does not autogenerate from
tables.py. Two PRs reached "green CI but PG-broken" and had to be held by
hand.

Add a cross-track guard, folded into the existing required schema-parity
job (no new required-check to manage):

- scripts/ci/check_alembic_parity.py — fails a PR that ADDS schema DDL to
  db/{migrations,schema,tables}.py without a net-new revision file under
  src/backend/migrations/versions/. Pure stdlib, PR-only (diffs base...head).
- Heuristic / false-positive guard: the signal is a DDL keyword on an
  *added, non-comment* line (SQL: CREATE/ALTER/ADD COLUMN/…; SQLAlchemy:
  Column(/Table(/Index(/…). Comment edits, data-only and down migrations
  carry no DDL keyword, so they don't trip it. Documented in the script
  docstring and the workflow header.
- tests/unit/test_alembic_parity_guard.py — 20 tests incl. the acceptance
  fixtures (SQLite-only change fails; dual-tracked passes; comment/data-only
  pass). Wired into the parity pytest run.

Also notes the enforcement in architecture.md Invariant #3.

Verified locally: 24 tests pass; end-to-end smoke across clean / SQLite-only
(exit 1) / paired-revision (exit 0) scenarios behaves correctly.

Related to #1342

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

* fix(ci): tighten Alembic guard to require a new MIGRATIONS entry (#1342)

Verifying against real repo history surfaced a false positive: the
migration-runner refactor #1263 (_atomic_rebuild table rebuilds) re-emits
CREATE TABLE / CREATE INDEX for *existing* tables in a rename-swap but adds
no actual schema and no new MIGRATIONS entry — yet the original "any added
DDL keyword" heuristic flagged it, violating AC #4 (non-schema edits must
not trip).

Tighten the signal to two conjuncts: a schema change must (1) register a
net-new ("name", _migrate_fn) entry in the MIGRATIONS list AND (2) carry a
DDL keyword. Runner refactors / table rebuilds add no entry → exempt;
data-only new migrations carry no DDL → exempt; real column/table adds do
both → caught.

Validated against real commits:
  • #740 agent_loops, #526 agent_ownership column → FAIL (correctly blocked)
  • #668 compat, voice_name (both shipped an Alembic revision) → PASS
  • #1263 runner refactor → PASS (false positive fixed)
A full post-Alembic history scan finds 0 outstanding missing revisions, so
no backfill is owed; the pre-Alembic columns are already in 0001_baseline.

28 tests pass (added MIGRATIONS-entry detection + the #1263 rebuild case).

Related to #1342

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Eugene Vyborov <eugene@beingluminous.com>

* docs(dependab…
vybe pushed a commit that referenced this pull request Jul 10, 2026
…04 (Phase 4) (#1497)

* test(canary): unblock canary_invariants fixture broken by #1472 merge

Two pre-existing breakages landed via #1472 (367a5e1, merged today) in
root-level tests/test_canary_invariants.py — a file the CI unit job
(tests/unit/ only) does not run, so they merged undetected:

1. Duplicate `CREATE TABLE agent_schedules` in the `canary_db` fixture DDL
   — the E-06 work added a second definition next to the pre-existing one,
   so `executescript` raised "table agent_schedules already exists" and
   every fixture-dependent canary test errored. Removed the older, unused
   block (its `message`/`owner_id` columns have no consumer; `_add_schedule`
   and the E-06/L-03 collectors use only the kept block's columns).
2. `test_run_invariants_all` expected registry set omitted "E-06" (added to
   the invariants registry by the same PR). Added E-06 to the expected set
   and asserted it is green on a clean platform.

Restores a green baseline (92 passed) so the #1450 B-01 work can be verified.

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

* fix(canary): B-01 queue-status coherence — backend-consistent Side B + confirm-re-read (#1450)

B-01 compared two reads that were neither temporally atomic nor
backend-consistent (both latent while the canary is default-OFF and SQLite
makes the two reads hit one file):

(a) Temporal non-atomicity — a concurrent enqueue/backlog-drain landing
    between the two reads produced a transient count mismatch → spurious
    critical + green→red Slack alert.
(b) Backend divergence (#300/#1093) — Side A (`db.get_queued_count`) honors
    `get_engine()`/DATABASE_URL; Side B read raw sqlite3 at DB_PATH. On
    Postgres those are two different databases, so B-01 compared Postgres
    truth to a stale/absent SQLite file (fatal under the Postgres direction +
    SQLite EOL, #1278).

Production-side residue of #1446 (PR #1452), which fixed only the test-harness
`sys.modules` leak and deferred these two gaps.

Fix (localized to B-01):
- New `_collect_queued_ids_via_engine` reads B-01's Side B through the SAME
  `get_engine()` seam as the accessor, on a dedicated `queued_ids_via_engine`
  snapshot field. Independent code path (SELECT id/literal 'queued' vs
  COUNT(*)/the QUEUED enum) — shares a database, not a code path, so a
  cache/status-filter regression still surfaces (non-tautology, AC #3). No
  cache / second count of the queue (AC #4).
- The collector performs one confirm-re-read on a mismatch: a transient race
  self-heals; a persistent drift survives and fires (AC #2). An engine-read or
  unconfirmable confirm degrades to a B-01 skip — it never compares an engine
  count against the raw-sqlite id-set (the blocker the reviews surfaced).
- `queued_exec_ids` (raw sqlite) stays untouched for B-02/E-02, so blast
  radius is B-01-only and the sibling #1077 merge stays clean.

The running-side / known-agents reads remain raw-sqlite (half-migrated
collector); the collector-wide migration + a dark-canary tripwire are a filed
follow-up.

Tests (test_canary_invariants.py): retarget the synthetic B-01 tests at
`queued_ids_via_engine`; add the AC #5 regression net — a diverged-backend
proxy (raw ≠ engine temp files) that false-fires pre-fix and is green
post-fix, a transient-race confirm-absorb + persistent-drift-still-fires pair,
and an engine-read-failure→skip test. New split fixtures (`canary_db_split` /
`reload_canary_split`) model the diverged backend without a live PG. Convert
the file to the sanctioned `_STUBBED_MODULE_NAMES` + `_restore_sys_modules`
escape hatch so its import-time stubs no longer leak (the #1446 mechanism) and
the reimport `del`s are lint-clean.

Docs: architecture.md Canary B-01 row updated for the engine-backed read path
+ confirm-re-read.

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

* docs(feature-flows): add #1450 canary B-01 Recent Updates row

Sync-feature-flows: canary-internal change, no dedicated flow doc
(architecture.md is canonical); append one Recent Updates row.

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

* test(canary): fix duplicate agent_schedules DDL + reconcile E-06 in registry test

The canary_db fixture had two `CREATE TABLE agent_schedules` statements in one
executescript (a #1472 merge artifact), so it raised `table already exists` and
reddened the whole file. Merge them into one definition carrying every column
the canary reads (next_run_at/enabled/deleted_at + agent_name). Add
duration_ms/queued_at/backlog_metadata to schedule_executions and extend
_add_execution for the #1077 E-03/E-04 collector work. Reconcile E-06 (already
registered) into test_run_invariants_all's expected key-set.

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

* feat(canary): terminal-row collector for E-03/G-03 (#1077)

Add _collect_terminal_rows(window_seconds) + Snapshot.terminal_rows. Windowed on
started_at (not completed_at, so E-03 can see NULL-completed_at rows), scoped to
success/failed/cancelled via a local _E03_TERMINAL_STATUSES subset that excludes
skipped (which legitimately has no completed_at/duration_ms). PRAGMA guard skips
the source entirely when completed_at/duration_ms are absent (column-absent !=
value-NULL) rather than false-firing. Window = max per-agent timeout + 300s;
bounded ORDER BY started_at DESC LIMIT 5000 with a logged sampled flag (no
(status,started_at) index over 90-day retention; tripwire, not backfill audit).

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

* feat(canary): register E-03 (completed_at populated) + G-03 (clock sanity) (#1077)

E-03 (A/major): terminal rows must have completed_at NOT NULL. Predicate is
completed_at-only — the catalog's + duration_ms clause false-fires on healthy
queue-terminated rows (cancel/fail/expire set completed_at but never
duration_ms). G-03 (A/minor): started_at <= completed_at with a ~1s cross-worker
clock-skew tolerance, UTC-aware parsing (E-06 _to_utc shape) so a #1474 mixed
naive/Z pair compares without raising. Both are leading-edge tripwires over the
shared terminal-row collector and catch all producers incl. the standalone
scheduler's raw-SQL writers a unit test never exercises.

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

* test(canary): E-03/G-03 synthetic + collector + end-to-end coverage (#1077)

Per-invariant synthetic tests (holds-clean, fires-on-violation), collector tests
(started_at window in/out, NULL-completed_at still collected, skipped-status
excluded, column-absent DDL -> unavailable, LIMIT cap + sampled flag), and
end-to-end collect_snapshot tests through the real collector: the C1
cancelled-from-queue holds-clean guard (completed_at set, duration_ms NULL ->
zero E-03), half-written fires E-03, bad-clock fires G-03, sub-second skew does
not, and the #1474 naive-vs-Z compare-without-raising case.

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

* docs(canary): document E-03/G-03 Phase 4 invariants (#1077)

architecture.md canary table gains E-03/G-03 rows + Phase 4 lookup-key line;
requirements/infrastructure.md §31 gains a Phase 4 bullet (and reconciles the
stale 'Phase 2 deferred' note now that #882/#1472 shipped);
orchestration-invariant-catalog.md annotates E-03/G-03 as shipped with explicit
registry-id mapping, notes the E-03 completed_at-only predicate deviation and
G-03 started_at<=completed_at reduction, and flags the catalog-id vs registry-id
E-06 drift (catalog #129 != registry #1472).

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

* docs(feature-flows): index row for canary Phase 4 E-03/G-03 (#1077)

Canary is an internal invariant harness (no user-facing flow / dedicated flow
doc); follows the #1446 precedent of a Recent Updates row pointing at
architecture.md.

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

* feat(canary): queued-row metadata collector for E-04/G-04 (#1077)

Capture queued_at + backlog_metadata for status='queued' rows in the
existing _collect_executions query, keyed by execution_id in a new
AgentSnapshot.queued_meta map. Both columns are PRAGMA-guarded (added by
BACKLOG-001): when either is absent on an older/minimal DDL the map is
left empty so E-04/G-04 skip those eids (older-image fail-open). Scoped
STRICTLY to queued rows — never terminal — so #1449's deferred
terminal-row backlog_metadata NULL-out cannot make E-04 false-fire.

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

* feat(canary): register E-04 (queued metadata) + G-04 (no creds in metadata) (#1077)

E-04 (Tier A, major): every status='queued' row has queued_at NOT NULL
AND a non-NULL, JSON-parseable backlog_metadata — the
backlog_service.drain_next replay contract. A malformed blob raises
JSONDecodeError and stalls the FIFO. Reports only the failed-predicate
reason code + ids, never the raw metadata (may carry credentials;
violations persist to canary_violations).

G-04 (Tier A, critical): a queued row's backlog_metadata matches no
known secret prefix (sk-/ghp_/gho_/ghs_/ghu_/github_pat_/xoxb-/xoxp-/
AKIA/AIza/sk_live_), word-boundary anchored so common substrings don't
false-fire. Rides E-04's collected bytes. Reports only the matched
pattern NAME + ids, one violation per row (stops at first match) — never
the secret, surrounding bytes, or raw metadata.

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

* test(canary): E-04/G-04 synthetic + collector + end-to-end coverage (#1077)

Collector test proves queued_meta is populated for queued rows only (a
terminal row carrying backlog_metadata is excluded — #1449-safe). E-04:
holds on valid rows; fires with the right reason code on NULL queued_at,
NULL backlog_metadata, and non-JSON metadata; skips an eid absent from
queued_meta (older-image fail-open); e2e over a real temp DB. G-04:
holds on benign metadata (incl. "task-" substring that must not
false-fire); fires on github_pat / openai / slack / aws exemplars; skips
NULL metadata (E-04 owns it). Every G-04/E-04 test asserts the secret /
raw metadata bytes appear NOWHERE in the persisted violation record. The
runner test now expects E-04 + G-04 in the registered invariant set.

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

* docs(canary): document E-04/G-04 Phase 4 invariants (#1077)

architecture.md lookup-key table gains E-04 + G-04 rows and the Phase 4
line now lists all four (E-04/G-04 stacked on #1450). requirements
infrastructure.md Phase 4 bullet expands to the full four-predicate set
with the credential-safety note. Catalog flips E-04/G-04 from
"gated on #1450" to SHIPPED, records the json.loads-vs-json_valid
implementation note, the queued-only scope, older-image fail-open, and
the report-reason/pattern-name-only security discipline; G-04 notes the
implemented check covers the backlog half of the title (log-line
scanning out of scope for #1077).

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

* docs(feature-flows): index row for canary Phase 4 E-04/G-04 (#1077)

Also corrects the header/separator ordering left malformed by the
earlier E-03/G-03 index-row commit (a data row had slipped above the
|---| separator).

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Eugene Vyborov <eugene@beingluminous.com>
anubis770 pushed a commit to anubis770/trinity that referenced this pull request Jul 16, 2026
…ink) (abilityai/trinity-enterprise#61)

Completes the Brain Orb write surface with the low-risk half of Phase 4:
owner/admin-only capture-a-note and link-two-notes. Split from the full
Phase 4 during /autoplan (two independent reviews) — run_skill (headless
claude -p exec), automatic transcript capture, and transcript injection
are deferred to trinity-enterprise#66 (they carry the exec/injection risk
and depend on an unproven Gemini-Live-transcription path).

Backend:
- routers/agent_brain_orb.py: GET /brain-orb/actions + POST /brain-orb/action
  (OwnedAgentByName). Action verb enum-validated at the boundary (run_skill/
  capture_transcript -> 400, never forwarded), body-capped (413), rate-limited
  per (user, agent, action), audit-logged, Idempotency-Key deduped (Invariant
  Abilityai#18, key folded per verb; effect_guard doesn't fit — no execution_id).
- services/brain_orb_voice_service.py: can_write folds capture_note/link_notes
  into the LOCKED voice manifest only for owners; shared users keep the
  read-only Phase-3 manifest. can_write computed in the mint route.
- config.py + routers/settings.py: BRAIN_ORB_WRITE_ENABLED kill-switch
  (default OFF, distinct from BRAIN_ORB_ENABLED) -> brain_orb_write_available.

Agent-server (Invariant Abilityai#5 mirror):
- routers/brain_orb.py: GET/POST /api/brain-orb/action[s] via the hardened
  _run_hook over the agent's ~/.trinity/brain-orb/action convention hook
  (agent owns the write, Invariant Abilityai#8; stdin-only, no shell string).

Frontend:
- orb.js: rewire initActions/postAction/doCapture/doLink from the dead
  localhost voice proxy (X-Orb-Token) to the broker + Bearer JWT +
  Idempotency-Key; un-hide #actions via body.brain-orb-write only after the
  broker confirms owner + flag + hook; add capture_note/link_notes to
  ORB_TOOLS; double-submit re-entrancy guard.
- orb-trinity.css / AgentBrainOrb.vue / stores/sessions.js: gate the panel on
  the write flag, relay writeAvailable in the init handshake.

Tests: 14 new cases (owner-gate 403, flag-off 404, no-hook 404, enum 400,
413, 429 + claim-release, idempotency replay/409, owner-only voice manifest,
agent-server action hook). 62/62 green. Security: /review 0 critical,
/cso --diff 0 findings.

Docs: requirements §46 FR-8, feature-flows/brain-orb.md Phase 4a, learnings
(effect_guard vs Idempotency-Key), cso-diff-2026-07-01 report.

Refs abilityai/trinity-enterprise#61

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
vybe pushed a commit that referenced this pull request Jul 17, 2026
… terminal (#1578) (#1680)

* docs(requirements): system-emitted task completion events (#1578)

Rule #1 — document the new capability before implementing it. Adds
§17.2a beside EVT-001: backend emits agent.task.completed/failed at every
CAS-won terminal, matching-sub gated, reserved namespace + recursion-break,
EVT-001 dispatch (best-effort to a running subscriber), inert by default.

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

* feat(events): event_dispatch_service — extracted delivery + system emit helper (#1578)

New services/event_dispatch_service.py:
- trigger_subscription / _interpolate_template / _get_internal_token moved
  verbatim out of routers/event_subscriptions.py so a service (TES) can reuse
  the EVT-001 dispatch primitive without importing a router (Invariant #1).
- emit_task_terminal_event + spawn_task_terminal_event (#1578): the single
  shared helper the backend fires at every CAS-won terminal — matching-sub
  gated (empty => no row, no dispatch), recursion-break on triggered_by='event',
  flat interpolable payload incl. fan_out_id/loop_id, fail-open.
- trigger_subscription stamps the reserved-namespace loopback with
  X-Event-Trigger so the spawned task persists triggered_by='event'.

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

* feat(events): reserved-namespace guards + recursion-break wiring (#1578)

- event_subscriptions.py: import the extracted event_dispatch_service (delete
  the moved defs); reject agent emits into agent.task.* on both emit routes
  (400); block reserved-namespace self-subscription on create AND update (400);
  dispatch via event_dispatch_service.trigger_subscription.
- chat.py: read X-Event-Trigger on /task; a reserved-namespace dispatch persists
  triggered_by='event' (threaded via triggered_by_override) so the emit helper
  suppresses that task's own terminal event — the recursion-break.

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

* feat(events): emit agent.task.* at every task_execution terminal (#1578)

- apply_result SUCCESS won path -> agent.task.completed (sanitized_resp, cost,
  duration); covers both #1083 paths (inline sync + async result-callback both
  converge here).
- apply_result FAILURE won path -> agent.task.failed (envelope.status carries
  failed/cancelled; salvage cost).
- _write_terminal_and_gate gains agent_name and emits agent.task.failed on won —
  covering the timeout / budget-exhausted / unexpected-exception (+ inline
  circuit-open/capacity/ephemeral) terminals that never reach apply_result, the
  exact wedge case the feature exists for. agent_name threaded from all 4
  execute_task call sites.

All emits are CAS-won only, fire-and-forget, fail-open.

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

* feat(events): emit agent.task.failed at the #1083 lease-reaper terminals (#1578)

_sweep_stale_slots' two per-execution CAS-won sites (async lease expired with no
result callback) now emit agent.task.failed — waking a subscribed orchestrator
on the exact wedge the feature exists for. Fire-and-forget + fail-open; the bulk
watchdog sweeps stay a documented residual (no per-row context).

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

* feat(events): emit agent.task.* at the pull-sink terminal (#1578)

apply_task_result's CAS-won path now emits agent.task.completed/failed. Dark
until a pull pilot is enabled (#1081) but wired so a pilot doesn't silently
regress report-back. CAS-won only (replay/late report short-circuits or loses
the CAS), fire-and-forget + fail-open. Coordinate with obasilakis (his file).

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

* docs(mcp): note reserved agent.task.* namespace in events.ts (#1578)

Invariant #13 doc-only: emit_event notes agent.task.* is reserved/backend-emitted
(rejected if emitted); subscribe_to_event notes you can subscribe to a source
agent's agent.task.completed/failed for an automatic report-back task instead of
polling, with the payload shape and the no-self-subscription rule.

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

* test(events): unit coverage for system-emitted task completion events (#1578)

27 tests, all green:
- Emit helper: fired (success/failed/cancelled), not-fired-no-sub (AC #1/#5),
  recursion-break, status-as-.value (#1085 footgun), payload shape
  (fan_out_id/loop_id), duration/cost row fallback, truncation, fail-open.
- Every CAS-won terminal writer spawns on won / not on lost CAS: apply_result
  inline path, _write_terminal_and_gate (timeout class — the critical pin), the
  #1083 lease-reaper, and the pull sink.
- Both #1083 terminal paths GENUINELY (dossier §5): inline apply_result call AND
  the async result-callback endpoint driving the REAL apply_result — distinct
  entry points, not two tests on the same inline call.
- Reserved-namespace guards: emit 400, create self-sub 400, PUT self-sub 400,
  cross-agent subscribe allowed.

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

* docs(feature-flows): task-completion-events flow + index + cross-links (#1578)

New feature-flows/task-completion-events.md: end-to-end report-back path, the
terminal-writer coverage table, system- vs agent-emitted split, 3-layer loop
safety, honest best-effort delivery note, content-trust note, test map. Adds the
Recent Updates row + category index entry (feedback: always add the index row),
and cross-link pointers from task-execution-service.md and
agent-event-subscriptions.md.

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

* docs(architecture): Task Completion Events cross-cutting block (#1578)

- New 'Task Completion Events (#1578)' subsystem block: system- vs agent-emitted,
  shared event_dispatch_service helper, CAS-won terminal-writer coverage table,
  3-layer loop safety, honest best-effort delivery note.
- Pointer from Fire-and-Forget Dispatch (#1083); services-catalog row for
  event_dispatch_service.py; reserved-namespace note on the events.ts MCP row +
  the agent_events DDL.

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

* docs(target-arch): reconcile §Async-First — completion events implemented (#1578)

Mark the agent.task.completed/failed contract IMPLEMENTED, note the divergence
(EVT-001 subscription dispatch, best-effort to a running subscriber; durable
pull-queue delivery deferred; not the WS event_bus) and the pull-sink coverage.
Add a '├─ Bankable win 3' #1578 row under the #1081 umbrella.

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

* fix(events): authenticate recursion-break header + sanitize task-event summary (#1578)

Two review findings on the #1578 completion-event path, both pre-merge:

1. Spoofable recursion-break. The reserved-namespace loopback was suppressed
   purely on an `X-Event-Trigger` header, so an external `/task` caller could
   spoof it to suppress a real agent's completion event. The header is now
   honored ONLY with a valid backend-internal `X-Internal-Secret` (C-003):
   `trigger_subscription` stamps both, `execute_parallel_task` verifies via
   `event_dispatch_service.verify_internal_dispatch_secret` (constant-time)
   before persisting `triggered_by="event"`.

2. Un-sanitized failure summary. Success/pull terminals sanitize upstream, but
   the failure error strings (`envelope.error` / `str(exc)`) reached the emit
   helper raw. `emit_task_terminal_event` now credential-sanitizes at the single
   chokepoint over a 2x-cap window BEFORE the final truncation, so a secret
   straddling the `TASK_EVENT_SUMMARY_MAX` boundary can't leak an un-redactable
   head fragment.

Tests: +7 (spoof-guard at the helper AND the router call-site; sanitize incl.
the boundary-straddle case) -> 37 pass. Docs: architecture.md delivery note +
feature-flow recursion-break/second-terminal notes updated.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Eugene Vyborov <eugene@beingluminous.com>
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