Release: v0.7.0 - #1319
Merged
Merged
Conversation
PR #871 added tests/unit/test_slot_per_slot_ttl.py with 6 sys.modules mutations (a local restore fixture + importlib stub injections) but didn't register them with tests/lint_sys_modules.py, turning the `lint (sys.modules pollution check)` gate red on dev and on every branch cut from it. Fix: promote the fixture's local `names` list to a module-level `_STUBBED_MODULE_NAMES` constant (completing the set to also cover the database/models/utils.credential_sanitizer/services.capacity_manager/ cleanup_service_direct stubs the importlib helpers inject). The lint recognises the top-level `_STUBBED_MODULE_NAMES` + `_restore_sys_modules` fixture pair as the sanctioned self-contained snapshot/restore pattern (precedent: tests/unit/test_telegram_webhook_backfill.py) and exempts the file. Bonus: the restore fixture now actually restores every stubbed module, so it no longer leaks into sibling test files. No behavior change to the #869 test logic itself. Related to #871 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
) PR #700 moved views/ApiKeys.vue → components/settings/McpKeysTab.vue and dropped the `copyToClipboard` import (#677's fix). Both copy buttons in the "Your MCP API Key is Ready!" modal threw `ReferenceError: copyToClipboard is not defined` and failed silently. - Add `import { copyToClipboard } from '../../utils/clipboard'`. - Promote the existing e2e regression (api-keys-copy.spec.js) @Interactive → @smoke so CI actually runs it (Option A from the issue), and point it at the canonical /settings?tab=mcp-keys route instead of relying on the legacy /api-keys 301-redirect. Audit: McpKeysTab.vue is the only component PR #700 moved into components/settings/; formatDate/getMcpConfig are local defs, so copyToClipboard was the only dropped import. Verified: `vite build` compiles clean (563 modules transformed, exit 0). Related to #859 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…883) * fix(config): forward SMTP + SendGrid env to backend container (#771) config.py reads SMTP_HOST/PORT/USER/PASSWORD (lines 50-53) and SENDGRID_API_KEY (line 55), but both docker-compose.yml and docker-compose.prod.yml forwarded only SMTP_FROM. Result: EMAIL_PROVIDER=smtp or =sendgrid silently fails with no error — the vars never reach the container. Forward all five in both files. Scope note: #771 listed 5 findings; verified against current dev, only 2 were still legit (this fix). The other 3 are stale (report dated 2026-05-11): - GOOGLE_API_KEY: now documented at .env.example:130 - FRONTEND_URL: single definition (:193); :147 is a deliberate cross-reference comment, not a contradictory duplicate - TRINITY_PASSWORD "changeme": gone — both compose files now use ${ADMIN_PASSWORD} consistently (prod fail-fast :?, local :-) Validated: `docker compose config` passes for both files. Related to #771 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(slots): adopt sanctioned _STUBBED_MODULE_NAMES pattern (#871 lint regression) PR #871 added tests/unit/test_slot_per_slot_ttl.py with 6 sys.modules mutations (a local restore fixture + importlib stub injections) but didn't register them with tests/lint_sys_modules.py, turning the `lint (sys.modules pollution check)` gate red on dev and on every branch cut from it. Fix: promote the fixture's local `names` list to a module-level `_STUBBED_MODULE_NAMES` constant (completing the set to also cover the database/models/utils.credential_sanitizer/services.capacity_manager/ cleanup_service_direct stubs the importlib helpers inject). The lint recognises the top-level `_STUBBED_MODULE_NAMES` + `_restore_sys_modules` fixture pair as the sanctioned self-contained snapshot/restore pattern (precedent: tests/unit/test_telegram_webhook_backfill.py) and exempts the file. Bonus: the restore fixture now actually restores every stubbed module, so it no longer leaks into sibling test files. No behavior change to the #869 test logic itself. Related to #871 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
#872) * test(infra): add ws_ticket fixture + websockets dep for ticket-based auth - conftest.py: add `ws_ticket` function-scoped fixture that mints a fresh single-use ticket via POST /api/ws/ticket. Returns a callable so tests can mint multiple tickets in one run (replay tests, etc). - requirements-test.txt: pin websockets>=13.0 for the sync client used by the rewritten /ws integration tests. Supports the #765 test rewrite for C-002 / #550 (WebSocket auth moved from JWT-in-URL to single-use opaque tickets). * fix(tests): rewrite /ws integration suite for ticket-based auth (#765) The old `test_ws_valid_token_not_rejected` asserted that `GET /ws?token=<JWT>` does NOT return 403 — which is exactly the behavior C-002 / #550 removed when WebSocket auth moved to single-use opaque tickets. The test was misleading the test suite into thinking there was a regression when the production behavior was correct. Rewritten suite (acceptance criteria from #765): - Mints a ticket via POST /api/ws/ticket, connects to /ws?ticket=<opaque>, asserts the upgrade is accepted (no 403). - Adds a negative test pinning the new behavior: /ws?token=<JWT> must return 4xx so the regression cannot recur silently. - Covers single-use (replay rejection via Redis GETDEL), malformed/empty ticket variants, and the tolerance case (extra ?token= ignored when ?ticket= is valid). - Cross-refs architecture.md "WebSocket Security (C-002, #550)" in the module docstring. Expiry (>30s TTL) is unit-tested separately in tests/unit/test_ws_ticket_service.py via FakeRedis — not duplicated here to keep the integration suite fast. /ws/events ?token=<MCP_API_KEY> remains supported per architecture.md and is out of scope for this file. Closes #765 * docs: update WebSocket auth flow for ticket-based model (C-002 / #550) Two stale references to the old `/ws?token=<jwt>` query param caught during the #765 test rewrite: - feature-flows/websocket-event-bus.md: update the /ws endpoint signature to `/ws?ticket=<opaque>` and point at the new ticket-mint flow + main.py line. - security/OWASP_COMPLIANCE_REPORT.md: replace the A01-1 remediation note (which still described JWT-in-URL as the current state) with the ticket-based flow rationale, including the April 2026 pentest finding 3.2.1 reference. * chore(frontend): remove unreferenced useProcessWebSocket composable Composable was authored for the Process-Driven Platform feature (removed upstream). Verified no remaining references in src/frontend/src/ via grep across .vue/.js/.ts files. Also opened a WebSocket using the old JWT-in-URL pattern (`localStorage.getItem('token')` → `?token=`), which no longer works post C-002 / #550, so leaving it in tree would be a foot-gun for anyone copy-pasting from it. * docs(feature-flows): sync /ws Security section for ticket-based auth Caught by /sync-feature-flows: the Security section narrative still described `/ws` as JWT-authenticated, even though C-002 / #550 moved it to single-use opaque tickets. The endpoint signature at line 39 was updated in the prior commit on this PR, but the Security section was missed. Updated to match architecture.md "WebSocket Security (C-002, #550)": ticket minted via POST /api/ws/ticket, 30s TTL, atomic Redis GETDEL, pentest 3.2.1 closed. `/ws/events` still accepts ?token=<MCP_API_KEY> per the documented wscat/websocat surface — clarified inline.
…follow-up to #798 (#873) * fix(agent-server): classify subprocess pipe-drop as 502, not 500 (#474) When the Claude/Gemini child process exits early (auth abort, permission-mode kill, upstream cancellation), the parent receives BrokenPipeError / ConnectionResetError on stdin write. The previous broad-except path logged [Errno 32] at ERROR and returned 500. Two problems with that: 1. SUB-003 in task_execution_service.py treats 503 from the agent as auth-class failure and triggers subscription auto-switch. 500 is adjacent and produces operator-noise; 502 ("Bad Gateway to Claude subprocess") is the semantically correct status here and is collision-free with the auto-switch path. 2. The ERROR log line was misleading — the agent itself is not faulted; the child process exited and the OS surfaced the pipe close. INFO is the right level. Adds parallel handlers in headless_executor.execute_headless_task and GeminiRuntime headless path. Tests pin: - pipe-drop returns 502 (not 500, not 503) - SUB-003 auto-switch is NOT triggered on this status Refs #474 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(monitoring): split client-pipe-drop from agent transport error (#474 Layer 2) check_network_health() now distinguishes two error classes that #798's narrow classifier treated as one: - BrokenPipeError / ConnectionResetError on a /health probe means the client-side socket died mid-flight (e.g., upstream MCP-sync cancellation cascading into the pooled keepalive). The agent's health hasn't been observed at all, so we MUST NOT record_failure(). Return reachable=False but stay circuit-neutral. - httpx.ReadError / WriteError / RemoteProtocolError on a /health probe ARE liveness signals — if the agent partially writes then drops (event-loop wedge, OOM mid-write, segfault), the agent IS unhealthy. record_failure() applies, distinct from the client-side pipe drop above. Tests pin the split — same exception types, opposite circuit semantics depending on whether the disconnect was client-side or agent-side. Refs #474 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(circuit-breaker): per-base_url drop-grace neutralises sibling-collapse (#474) Follow-up to #798's narrow classifier. That fix correctly stopped ReadError/WriteError/RemoteProtocolError from incrementing the circuit, but it didn't address the eviction-then-fresh-client race during a concurrent transport-drop burst: when one caller catches a pipe drop and evicts the pool entry, sibling callers race to build a fresh client against a half-closed peer and see ConnectError/TimeoutException. Under the old classifier those got record_failure(), so 9-of-10 concurrent drops still tripped the breaker on a healthy agent. This patch adds: - `AgentConnectionDroppedError` (subclass of AgentNotReachableError) — distinct typed signal for "in-flight transport broke" vs "agent unreachable from the start". Inherits from AgentNotReachableError so existing tenacity `retry_if_exception_type` chains and callers catching AgentNotReachableError are unaffected. - `_recent_drops: Dict[base_url, monotonic_ts]` + `_DROP_GRACE_SEC=2.0` — first caller to catch a transport drop stamps the base_url; siblings whose fresh-client retry fails with ConnectError/Timeout within the grace window are classified as collateral drops and raise AgentConnectionDroppedError without record_failure(). - `_acquire_client(base_url) -> (client, is_pooled)` — replaces `_get_http_client`. While a drop-grace window is active, returns a fresh single-use client (so the pool isn't repopulated with transient sockets during a burst); the caller's `finally` closes it. `_get_http_client` retained as backward-compat wrapper. - Explicit handlers for ReadError/WriteError/RemoteProtocolError/ BrokenPipeError/ConnectionResetError that stamp the drop, evict the pooled client (with an `is client` identity check so siblings don't double-close), and raise AgentConnectionDroppedError. Scope: both `_recent_drops` and `_client_pool` are process-local. Under multi-worker uvicorn deployments each worker has its own grace map and pool, so the burst-neutralisation is per-worker. The Redis-backed circuit (`CircuitState`) remains the single fleet-wide source of truth, so transport drops still never hit `record_failure()` in any worker. Tests (both unit + integration) pin: - Burst of 10 concurrent transport drops produces 0 record_failure calls (was 9 under #798's classifier alone). - Sibling ConnectError inside the grace window is collateral (no record_failure); same ConnectError outside the window is a real failure. - Pool eviction is idempotent across siblings (no double-close). - Non-pooled clients are always aclose()d on every exit path. docker-compose.sibling.yml: minimal Redis-only sibling override (port 6390, project `trinity-sibling`) for running test_circuit_breaker integration tests against a real Redis without spinning the full production stack. Refs #474 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(security): CSO --diff audit report for #474 follow-up Self-contained security audit of the uncommitted working tree on this branch (HEAD == merge-base with origin/dev pre-merge) before opening the PR. 0 critical / 0 high / 0 medium across secrets, deps, auth, injection, and platform patterns; 1 low (configuration); 2 info. Refs #474 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(flows): sync feature flows with #474 follow-up changes Three flows updated to reflect the commits earlier in this branch: - agent-monitoring.md: revision-history entry for the check_network_health() exception-classification split (commit d53a2d6) — BrokenPipeError/ConnectionResetError as client-side drops (no record_failure) vs httpx.ReadError/WriteError/ RemoteProtocolError as agent liveness signals on /health (record_failure). Line range for check_network_health updated 170-269. - execution-queue.md: revision-history entry for the agent_client drop-grace coordination (commit c9d6a09) — _recent_drops map, AgentConnectionDroppedError, _acquire_client tuple API, pool-eviction identity check. agent_client.py file-stats line count refreshed to 1130. Response-data-class and parsing-logic line refs realigned to post-rewrite positions. - parallel-headless-execution.md: revision-history entry for the subprocess pipe-drop reclassification (commit 1cdbc57) — 502 not 500, log demotion to INFO, no SUB-003 503-auth-class collision. Updated >Updated< front-matter line. Refs #474 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(circuit-breaker): broaden TimeoutException + /health-timeout liveness (#474) - agent_client: TRANSIENT_TRANSPORT_EXCEPTIONS now uses httpx.TimeoutException (parent class) instead of enumerating Read/Write/Pool — covers any future subclass without re-touching the tuple. ConnectTimeout stays in CIRCUIT_FAILURE_EXCEPTIONS above (first-match in _request() wins). - monitoring_service: lift CIRCUIT_FAILURE_EXCEPTIONS + TRANSIENT_TRANSPORT_EXCEPTIONS imports to module-top (with ImportError fallback for stub-fixtures) so test patches replacing services.agent_client with a MagicMock don't turn them into non-exception values that fail `except` at runtime. - monitoring_service: add /health-specific `except httpx.TimeoutException` ABOVE the transient handler — for /health a timeout is a liveness signal (event-loop wedged), so record_failure() applies, opposite contract to AgentClient._request(). - monitoring_service: stabilise user-facing error string to "Connection refused" / "HTTP timeout"; full classname+message stays in logger.debug for triage, no longer leaks into dashboards. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(qa): regression coverage for gemini_runtime pipe-drop 502 (#474) ISSUE-001 — Found by /qa on 2026-05-17 Report: .gstack/qa-reports/qa-report-localhost-2026-05-17.md The #474 follow-up added BrokenPipeError/ConnectionResetError handling to GeminiRuntime.execute_headless (gemini_runtime.py:728-739), parallel to the Claude path in headless_executor.py:856-872. The Claude path ships with tests/unit/test_headless_executor_pipe_drop.py (3 tests); the Gemini path had none. This file mirrors the Claude regression suite: - BrokenPipeError → INFO log + HTTP 502 + descriptive detail - ConnectionResetError → INFO log + HTTP 502 - RuntimeError → ERROR log + HTTP 500 (negative case: branch must not absorb non-pipe failures) - TimeoutError → HTTP 504 (negative case: branch must not steal timeout classification, which is layered above the pipe handler) Fixture pattern: monkeypatch GeminiRuntime.is_available -> True and plant the exception via gemini_runtime.subprocess.Popen so the outer except block is reached. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(security): CSO --diff audit report for #474 follow-up commits Covers the four commits added since the 2026-05-13 report (`cso-2026-05-13-474-diff.md`): per-base_url drop-grace (c0599c2), monitoring split (7831a81), TimeoutException broadening + /health timeout liveness (7af831f), and regression coverage (58c2ec4). Result: 0 CRITICAL / 0 HIGH / 0 MEDIUM / 0 LOW. Two INFO-level positive notes — stable user-facing error strings narrow info-disclosure surface in fleet-health UI; sibling Redis compose review is clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… leak (#888) Three-layer fix for P0 privacy bug: platform guardrail + write_user_memory MCP tool (server-side email resolution from execution_id) + execution_id in execution context. Includes architecture.md and requirements.md updates.
…on email (#890) (#892) * feat(email): add context, agent name, and HTML template to verification email (#890) - extend send_verification_code() with optional agent_name and context_label params - subject now reads e.g. 'Your Trinity access code for "Research Assistant"' or 'Your Trinity login verification code' - plain-text body names the agent/context and explains why the code was sent - HTML email added with clean layout and large prominent code block - auth.py passes context_label="Trinity login"; public.py passes agent_name from link Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs(feature-flows): sync flows for #888, #890, #873 - email-authentication.md: add #890 revision entry (contextual subject/body, HTML template) - public-agent-links.md: note agent_name now passed to verification email - write-user-memory.md: new flow for write_user_memory MCP tool (#888) - gemini-runtime.md: pipe-drop reclassification to 502 (#873) - parallel-headless-execution.md: same pipe-drop fix in headless_executor, useProcessWebSocket.js deleted - agent-monitoring.md: note 502 handled correctly by existing health check classification Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
…895) (#896) Two MEM-001 bugs: 1. Storage conflict — write_user_memory (#888) and the every-5-message conversation summarizer both overwrote public_user_memory.memory_text, so deliberate agent writes got clobbered by the next Haiku summary. 2. Channel injection gap — Slack/Telegram/WhatsApp channel sessions never injected the memory block into the agent's system prompt, breaking the cross-channel continuity goal of MEM-001 + #311. Storage is now JSON {agent_notes, conversation_summary} inside the existing TEXT column — no schema migration. write_user_memory updates only agent_notes; the summarizer updates only conversation_summary. Legacy plaintext rows surface as conversation_summary transparently. Channel adapters now mirror the web injection in adapters/message_router._handle_message_inner, gated on verified_email and not is_group. Group mode is excluded because the verified email there is the unlocker's, not the speaker's — injecting it into group replies would leak PII across users. The summarizer was extracted to services/platform_prompt_service so web and channel paths share it. format_user_memory_block now takes the parsed dict and emits both sections (agent notes first), returning None when both are empty so callers skip the --append-system-prompt injection. 21 new unit tests cover the parser (incl. legacy plaintext), split storage write semantics, formatter multi-section rendering, and the channel-injection gating logic. Known residual: the section writes use Python-side read-modify-write; two concurrent writers within ~10ms can still race (much narrower than the pre-fix deterministic clobber). Atomic SQLite JSON1 UPSERT is a follow-up. Fixes #895 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…sed (#887) (#893) * fix(read-only): bake guard into base image, cover MultiEdit, fail-closed (#887) The read-only guard was stored in the agent-writable .trinity/hooks/ path and injected dynamically into settings.local.json. An agent could overwrite the guard script or the hook registration, and MultiEdit calls were never checked (no top-level file_path). - Move read-only-guard.py to /opt/trinity/hooks/ (root-owned 0555 in base image) - Register hook permanently in ~/.claude/settings.json via claude-settings.json (matcher now includes MultiEdit) - inject_read_only_hooks() writes ONE file only: ~/.trinity/read-only-config.json - remove_read_only_hooks() writes {"enabled": false}; strips legacy settings.local.json hook entry via _remove_legacy_settings_hook() for pre-#887 agents - lifecycle.py always syncs config on every agent start (both enable and disable paths) to prevent stale enabled:true config persisting on the volume - Add path_deny and bash_deny in guardrails-baseline.json to protect config file - Wrap main() in run_hook() for fail-closed behavior (uncaught exception → exit 2) - Add 18 unit tests in tests/unit/test_read_only_guard.py (all passing) Fixes #887 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs(flows): sync feature-flows.md and test catalog for #887 - docs/memory/feature-flows.md: add #887 entry to Recent Updates table - .claude/agents/test-runner.md: add 6 new unit test files (49 tests) from commits #887, #890, #873 to categories + Recent Test Additions (2026-05-18); bump unit test count ~207→~256, total ~2300→~2349 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(read-only): stub remove_read_only_hooks in readiness probe fixture PR #893 added `remove_read_only_hooks` to lifecycle.py's import line (`from .read_only import inject_read_only_hooks, remove_read_only_hooks`) to support the always-sync-on-start behavior. The readiness-probe test fixture stubs `services.agent_service.read_only` so lifecycle.py can be loaded in isolation, but the stub only exposed `inject_read_only_hooks`. Result: lifecycle.py module load raises ImportError during test collection, so all 5 tests in test_agent_readiness_probe.py error out. Caught by the regression-diff CI job. Fix: add `remove_read_only_hooks=None` to the SimpleNamespace stub. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
…by default (#860) (#863) * feat(workspace): gate Agent Workspace behind admin feature flag, off by default (#860) - Add is_workspace_enabled() to settings_service.py (opt-in via WORKSPACE_ENABLED env var or system_settings DB row; default False) - Expose workspace_available in GET /api/settings/feature-flags, computed as voice_available AND is_workspace_enabled() - Add workspaceAvailable state to sessions.js Pinia store - Thread workspaceAvailable as new prop to AgentHeader; gate workspace button with v-if="workspaceAvailable" instead of voiceAvailable - Add beforeEnter route guard on /agents/:name/workspace that redirects to AgentDetail when workspace is disabled (closes URL bypass) - Add two integration tests to TestFeatureFlagsEndpoint covering key presence and default-off behaviour Fixes #860 Co-Authored-By: Claude <noreply@anthropic.com> * docs(architecture): add workspace_available to feature-flags endpoint entry (#860) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
#897) Option A (GitHub-native, lowest friction) of the security vulnerability monitoring issue: - New .github/workflows/codeql.yml: CodeQL static analysis for Python and JavaScript/TypeScript on push + PR to dev/main and a weekly schedule. No Go module in the repo, so no Go target. Free for public repos. - dependabot.yml: add the `docker` ecosystem covering every Dockerfile under docker/{base-image,backend,frontend,scheduler}, grouped, weekly — closes the base-image CVE gap (Python/Node/nginx FROM lines). - Created repo labels `dependencies` and `docker` so Dependabot PRs are filterable (AC requires labeled output). Repo-admin-only settings (Dependabot alerts, automated security fixes, secret scanning + push protection) cannot be toggled via API with maintain permission — documented as a manual step in the PR body. Related to #850 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…685) (#899) User feedback: the 'Reset memory' button's purpose and how it differs from starting a new session was unclear. - Rename button + modal confirm to "Clear working memory" (accurate: it clears Claude's cached resume context; history is preserved). - Sharper tooltip: what it does, when to use it (stuck/looping), and that history is kept and it's not the same as + New Session. - Modal body rewritten with an explicit contrast paragraph vs + New Session (brand-new conversation vs same session, history kept). - Post-compaction inline hint now names "+ New Session" instead of the ambiguous "start fresh". - Error string + dev comment aligned to the new label. Frontend-only (SessionPanel.vue), no behavior/API/DB change. Verified: vite build clean. Related to #685 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat: A2A v1.0 Agent Card endpoint per agent (#737 Phase 1) Trinity agents now publish A2A-protocol Agent Cards so external orchestrators (AWS Bedrock, Azure Copilot, Google ADK) can discover them without knowing Trinity's internal API. GET /api/agents/{name}/a2a/agent-card Returns a valid A2A v1.0 card built from `template.yaml`: - `protocolVersion` "1.0" - `name`, `description`, `version` from template fields with sane fallbacks (display_name → name → agent_name; description → tagline → "Trinity agent: <name>") - `skills[]` mapped from `capabilities[]`: one skill per capability, with the agent's `use_cases[]` distributed as `examples` on each - `capabilities.streaming = true` (agent-server's SSE is always on), pushNotifications + stateTransitionHistory false (not in surface) - `securitySchemes.bearerAuth` declared — orchestrators attach a Trinity MCP API key - `url` points to the public chat endpoint as a working placeholder; the dedicated A2A JSON-RPC endpoint is a follow-up (#737 ack'd this explicitly) Implementation - `services/a2a_card_service.py` — pure mapper from template_data dict → A2A card dict. Defensive on capability shapes (non-strings, whitespace, missing use_cases). JSON-serializable contract asserted in tests. - `routers/a2a.py` — new router; auth via `AuthorizedAgentByName` (same gate as the rest of the per-agent endpoints). Fetches template.yaml data from the agent-server's `/api/template/info`; falls back to Docker labels when the agent is stopped, the network is unreachable, or `has_template=false`. Never 5xx's the card endpoint on transient agent failures. - `routers/a2a.py:_base_url_from_request` — resolves card `url` from PUBLIC_CHAT_URL → FRONTEND_URL → request.scheme+host → empty (in which case the generator omits `url`). Phase 1 scope (rest of issue's checklist explicitly deferred) - Redis caching: not yet — template.yaml is read each call which is cheap, and there's no observed traffic that needs caching - Extended card variant (auth-only fields like internal URLs): deferred — public card covers the discovery contract - `/.well-known/agent-card.json` host-root proxy: deferred — decision on convention (subdomain / path / header) deferred to the routing pass; per-agent path serves orchestrators that fetch by URL today - MCP tool `get_agent_card`: deferred — lives in the MCP server, not this PR - A2A JSON-RPC server (where the card's `url` would ideally point): deferred — separate ticket Tests 11 unit tests in `tests/unit/test_a2a_card_service.py` cover: happy-path skills mapping, label-fallback shape, missing-field defaults, version coercion, defensive capability shapes (non-strings/whitespace/empty), and a JSON-serializable contract. Live verification Smoke-tested on the running stack against a freshly-created agent. Endpoint responds with a valid A2A v1.0 JSON document; auth gate behaves correctly; fall-back path (when /info returns `has_template=false`) produces a well-formed card from Docker labels. The "skills from capabilities" path can't currently be live-verified on this instance because: - trinity-system has full template.yaml but is detached from trinity-agent-network (so the backend's HTTP proxy hits DNS failure — same fallback the existing `/info` endpoint shows) - Newly-created agents are on the network but their workspace doesn't receive a copy of `template.yaml` from the local-template source path (separate bug in `services/agent_service/crud.py`, out of scope for #737) Unit tests cover the populated-skills path end-to-end and pass; the endpoint will produce richly-populated cards as soon as either of the above environmental issues resolves on production instances. Related to #737 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(a2a): fix spec URL + add architecture/requirements entries (#737) Addresses @vybe's CHANGES_REQUESTED review on PR #842: 1. A2A spec URL was wrong — `https://github.com/anthropics/a2a-protocol` doesn't exist. A2A is Google's open protocol. Corrected the docstring reference in a2a_card_service.py to https://google.github.io/A2A/ and clarified it's Google's. 2. architecture.md — added `GET /api/agents/{name}/a2a/agent-card` to the Agents API table; bumped the endpoint count 32 → 33. 3. requirements.md — added §32 "A2A Agent Discoverability (#737)" as a new platform capability, Phase 1 marked 🚧 with the deferred Phase 2 scope (Redis cache, extended card, /.well-known proxy, MCP tool, JSON-RPC server) enumerated. No functional code change — docstring + docs only. Related to #737 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… B-02, R-01) (#884) * feat(#882): canary invariant harness Phase 2 (S-02, E-01, E-05, B-01) Adds four single-source SQL/Redis invariants to the canary harness (#411). All four follow the Phase 1 (#653) pattern — no new source types, no new infrastructure, registered into the same `INVARIANTS` dict the run-cycle endpoint and background loop already drive. - S-02 — No overbooking. `ZCARD(agent:slots:A)` (drain sentinels filtered) > `max_parallel_tasks`. Critical. Tier A. Catches `acquire_slot` bypass — distinct from S-01 because the violation can be self-consistent (Redis and SQL agree on N+1 vs cap of N). - E-01 — Terminal-state closure. No `status='running'` row older than `execution_timeout_seconds + 300s` (matches `SLOT_TTL_BUFFER` so the check fires *after* cleanup has had its window). Critical. Tier B. - E-05 — Dispatched rows have session. No running row older than 60s with `claude_session_id IS NULL`. Major. Tier B. Guards #106. - B-01 — Queue-status coherence. `db.get_queued_count` (the accessor BacklogService calls) agrees with the snapshot's independently- collected `len(queued_exec_ids)`. Critical. Tier A. Trivially-green today after the #428 consolidation; regression guard against a future cache layer or status-filter drift on the production accessor. Snapshot extended with per-execution `claude_session_id` (E-05) and per-agent `queued_count_via_service` (B-01). The session-id collector PRAGMA-introspects the column so the minimal unit-test DDLs don't have to mirror every production column. The service-count collector lazy-imports `database.db`, returning `None` on import failure so unit tests (which stub `db.connection` but not the full facade) skip B-01 silently rather than firing a false positive. Unit tests: 67 passing (was 51). Each new invariant has positive, negative, and edge-case tests. Verification against local stack — for each invariant, provoke, run `POST /api/canary/run-cycle`, observe red, revert, observe green. All four reproduce as designed: - S-02 — ZADD'd 3 fake slot ids when max_parallel=2 → critical violation with `overbooked_by: 1`. - E-01 — inserted `status='running'` row with `started_at` 2h ago against a 60s-timeout agent → critical violation, `age_seconds: 7436 > timeout+buffer=360s`. - E-05 — inserted `status='running'` row 3 min old with `claude_session_id` NULL → major violation, age=188s. - B-01 — temporarily patched `db.get_queued_count` to return `count - 1` → critical violation, "db.get_queued_count = 0 != |queued ids in snapshot| = 1". Each post-fix cycle returned `violations: 0, transitions: 0`. Refs: #411, #653, docs/testing/orchestration-invariant-catalog.md * feat(#882): canary harness Phase 3 (S-03, B-02, R-01) Adds three moderate-complexity invariants on top of Phase 2. Each brings exactly one new piece of plumbing — first time the canary takes a hard dep on a non-trivial source beyond SQLite + Redis basic ops: - S-03 — Slot TTL ≥ execution timeout. For every member of `agent:slots:A`, the companion `agent:slot:A:{eid}` HASH must have `TTL ≥ execution_timeout_seconds + 300s` (SLOT_TTL_BUFFER). Three failure kinds surfaced explicitly: `missing` (-2; the #226 class), `no_expiry` (-1), `below_floor` (positive TTL under floor). Critical. Tier A. Per-slot `redis.ttl()` lookup, bounded by ZCARD per agent (≤ max_parallel_tasks). - B-02 — No queued without slots-full. If any agent has queued > 0, then either `slot_count == max_parallel` (legit backpressure) OR a drain tick fired in the last 60s (drain will pick it up). Critical. Tier B. Requires `CapacityManager.run_maintenance()` to write a unix-timestamp heartbeat to `canary:drain_tick_at` at the END of each successful sweep — mid-sweep crash leaves cursor stale and lets B-02 catch the breakage. One-line write in capacity_manager.py, rest is canary-local. - R-01 — No zombie Claude processes. For every running `trinity.platform=agent` container, `ps -eo stat,comm | grep '^Z.*claude' | wc -l` must be 0. Critical. Tier A. Guards PR #407. New source type — docker exec via the existing docker_service.docker_client. Per-container failures recorded in `sources_unavailable` so a single unhealthy container doesn't kill the cycle. Regex anchored at `^Z` rather than the catalog's ` Z` (leading-space) — procps-ng on the agent base image emits STAT left-aligned without padding; verified live by spawning an actual zombie via `os.fork()`+`prctl(PR_SET_NAME, "claude")`. Snapshot extended: - `AgentSnapshot.slot_ttls: Dict[str, int]` — per-slot metadata TTL, drain sentinels skipped at collection time. - `Snapshot.drain_tick_at: Optional[float]` — read from `canary:drain_tick_at`, sentinel-`None` on cold cluster. - `Snapshot.zombie_counts: Dict[str, int]` — per-agent zombie process count via container.exec_run; missing entry = exec failed for that container (recorded in `sources_unavailable`). Tests: 67 → 84 passing. Added `fake_docker` fixture so the synthetic container list is controllable; FakeRedis got a `ttl()` method with the standard -2/-1/positive sentinel semantics. Verification against local stack — for each invariant, provoke, run `POST /api/canary/run-cycle`, observe red, revert, observe green: - S-03: ZADD a slot + EXPIRE its metadata HASH to 30s while the floor is 360s → critical violation `kind: below_floor`. Also covered the `missing` kind by deleting the HASH entirely. Revert by EXPIRE 500. - B-02: inserted 1 queued row, set `canary:drain_tick_at` to 600s ago → critical violation, `free_slots: 2, drain_tick_age_seconds: 600`. Revert by writing a fresh timestamp. - R-01: spawned a real zombie inside agent-cornelius-m via Python fork + prctl PR_SET_NAME → critical violation `zombie_count: 1`. Reaped by killing the parent → green. All post-fix cycles returned `violations: 0, transitions: 0`. Final all-10-invariants cycle on clean platform: 106ms cycle duration, all green, `sources_unavailable: []`. Refs: #411, #653 (Phase 1), #884 (this PR — Phase 2 also) * fix(canary): /review fixes — alert quality + B-02 boot-window false-positive Addresses three findings from the pre-landing /review pass: I1 — Alert quality for Phase 2 + 3 invariants. canary_alerts.py only had S-01/E-02/L-03 entries in `_INVARIANT_NAMES`, `_INVARIANT_RUNBOOKS`, `_render_message`, and `_render_forensic`. New ids fell through to the "S-02 fired N violation(s)" generic fallback with the id doubled in the header. Added 7 entries each: - Friendly name and one-line runbook hint per invariant - Per-id `_render_message` (e.g. "3 zombie claude process(es) across 1 agent(s): cornelius-m" for R-01) - Per-id `_render_forensic` rendering of the relevant observed_state fields, truncated to 5 violations with a "+N more" footer Verified by hand-building an R-01 ViolationReport and inspecting the Block Kit payload — header, body, forensic, runbook, and context all render the new shape. I2 — B-02 boot-window false-positive. Background canary loop is fine (30s startup vs 15s maintenance loop), but the on-demand `POST /api/canary/run-cycle` endpoint can hit in the first 15s when no heartbeat exists. With pre-existing queued rows and free slots, B-02 would fire with `drain_tick_age_seconds: null`. `CapacityManager.__init__` now seeds the heartbeat with a fresh timestamp on construction. The maintenance loop overwrites on every successful tick; init only needs a non-stale floor. Verified live: deleted the heartbeat key, bounced the backend, key is present immediately — no waiting for the maintenance tick. I3 — Stale docstring in `_collect_zombie_counts`. The docstring still described the catalog's ` Z.*claude` (leading-space) reasoning while the actual `cmd` line uses `^Z.*claude`. Updated to describe the anchor-at-line-start version and reference the live-zombie verification. Bonus tidy: moved `import time` from inside `run_maintenance` to the top-of-file imports. Tests: 84/84 still green. Full all-10-invariant cycle clean. Refs: /review pass on #884 * feat(canary-fleet): replace long with sleep-echo slow agent `canary-fleet-long` was a duplicate of burst — same template, same task duration, same model — only the cron differed (*/5 vs */2). It added no coverage burst didn't already provide for S-01, E-02, S-03, R-01. Replace it with a `slow` agent backed by a new `sleep-echo` local template that sleeps 75s per task. This gives Phase 2 invariants something to inspect: - E-05 (dispatched rows have session): needs >60s running rows; was trivially-green with 4s test-echo tasks - S-03 below_floor: needs a slot to exist at canary snapshot time Also locks burst's live config into the yaml so a redeploy doesn't revert prior live SQL fixes: - cron: * * * * * -> */2 * * * * (cheapest cadence that phase-slides against the 5-min canary cycle) - description / comments updated to reflect actual coverage scope Manifest deploy can't express `model`, `max_parallel_tasks`, or `execution_timeout_seconds` (system_service.create_schedules drops them, SystemAgentConfig has no slot for capacity). Documented the four required post-deploy API calls in the yaml header so the next operator doesn't trip on it. * chore(lint): regenerate sys.modules baseline to absorb dev state Pre-existing CI failure inherited from dev — `dev` has been failing this lint since #871 (commit 98574f3) merged on 2026-05-17. That PR added 6 sys.modules violations in tests/unit/test_slot_per_slot_ttl.py without regenerating the baseline; a separate cleanup retired 3 violations in tests/unit/test_cleanup_unreachable_orphan.py. Regenerated via `python tests/lint_sys_modules.py --regenerate-baseline` — the path the lint script itself directs you to when violations move below baseline AND new files exceed it. Net: 235 violations in 67 files (unchanged total). No code-quality regression in this PR's actual diff — none of the canary tests use bare sys.modules manipulation (they use monkeypatch.setitem throughout).
…Phase 1a) (#838) * feat(soft-delete): agent_ownership soft-delete + retention purge (#834 Phase 1a) Replaces the hard-delete on `DELETE /api/agents/{name}` with a two-stage lifecycle: mark `agent_ownership.deleted_at` immediately, then hard-purge (cascading every child table via #816's primitive) after a configurable retention window. Default 30 days, settable via `agent_soft_delete_retention_days` in system_settings. What changes for an operator - Accidental `DELETE /api/agents/X` is no longer destructive. Chat history, schedules, sharing, permissions, credentials, MCP key, and on-disk workspace volumes all survive until purge. Recovery is a manual `UPDATE agent_ownership SET deleted_at=NULL` while the retention window holds (full UI/admin endpoint for recovery is Phase 1b, separate PR). - Agent names are reserved during the retention window — creating a new agent with the same name fails with 409 until the soft-deleted row is purged. Prevents accidental name collision with the deleted agent's lingering Redis state. - Docker containers remain ephemeral and are removed at delete time (issue acceptance criterion). Only the relational metadata and the workspace volume survive. What changes for an end-user (API consumer) - Nothing visible: `GET /api/agents/{name}` returns 404 for soft-deleted agents, `GET /api/agents` excludes them, every other per-agent read returns the same response as if the agent never existed. 404 transparency is an acceptance criterion of #834. Implementation 1. Schema: `deleted_at TEXT` on `agent_ownership` + partial index `WHERE deleted_at IS NOT NULL` so the retention sweep stays cheap as the live agent count grows. Versioned migration in `db/migrations.py`. 2. `delete_agent_ownership()` flipped from DELETE to `UPDATE deleted_at = NOW`. Idempotent on re-delete. `purge_agent_ownership()` runs the #816 `cascade_delete()` and then drops the parent row; refuses to operate on a row that isn't already soft-deleted. 3. `find_soft_deleted_agents_past_retention()` drives the cleanup sweep, bounded at 5000 rows/cycle per the existing #772 pattern. 4. Read-path audit: 35 SELECT/JOIN sites against `agent_ownership` now filter `WHERE deleted_at IS NULL`. The 4 unfiltered sites are intentional and commented: - `purge_agent_ownership` internals (need to see the soft-deleted row) - `rename_agent` uniqueness check on the destination name (name reservation acceptance criterion) - canary snapshot's `known_agents` (soft-deleted-pending-purge agents legitimately have child rows in live tables until the sweep runs — treating them as orphans would surface false positives in L-03) 5. `is_agent_name_reserved()` added as an unfiltered companion to `get_agent_owner()` — the create flow uses it to catch the "name held by a soft-deleted agent" case without the false-OK that filtering produces. 6. `cleanup_service.py` gains a sweep block that reads `agent_soft_delete_retention_days`, finds eligible rows, runs `purge_agent_ownership()` on each. Cycle count surfaced in `CleanupReport`. 7. Setting registered with default 30 days; "0" disables. Live verification on the running stack (uvicorn auto-reload picked up every change): - Migration ran cleanly on backend restart (`deleted_at` column present, partial index created) - Create → delete: `agent_ownership` row stays with `deleted_at` populated; `agent_sharing`, `agent_tags`, `mcp_api_keys` all survive - `GET /api/agents/{name}` returns 404 - `GET /api/agents` doesn't list the soft-deleted agent - `POST /api/agents` with the soft-deleted name returns 409 (name reserved) - `db.purge_agent_ownership(name)` cascades correctly - After purge, the name frees; recreation succeeds Dependency note This PR vendors `src/backend/db/agent_cleanup.py` from PR #829 (#816) so the cascade primitive is available even if #829 lands after this. If #829 merges first the file is identical and the merge is a no-op; if this PR merges first, #829's merge becomes a no-op for that file. Either order is safe. Out of scope (later phases) - Phase 1b: extend pattern to `agent_schedules`, `users` (auth-path implications), `agent_shared_files`, `agent_sessions`, `chat_sessions` (issue lists all six entities; doing each in its own PR per "validate the pattern before applying to risky tables"). - Admin endpoint to LIST + RECOVER soft-deleted agents (issue acceptance criterion). Today an operator does it via direct DB UPDATE — Phase 1b adds the API surface. - Container recreate-on-recover (preserved workspace volume + fresh container). Today recovery is metadata-only. Related to #834 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(soft-delete): bump default agent retention 30 → 180 days (#834) 180 days is a more conservative recovery window — gives an operator who soft-deleted an agent in error roughly half a year to notice and recover. Disk cost for the parked relational metadata is small relative to the workspace volume that has to coexist with it anyway. Operators on a tight disk budget can override via `agent_soft_delete_retention_days` in `system_settings`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tests): unblock #834 PR — DB_PATH patch + ephemeral schema (#834) CI on PR #838 had two failures: 1. **Lint (sys.modules pollution check)**: my new `tests/unit/test_agent_soft_delete.py` had a `sys.modules[spec.name] = module` write inside the importlib loader — same lint trip as #602/#830. The modules being loaded (`utils.helpers`, `db.connection`) don't use `@dataclass(frozen=True)` so the registration is unneeded; drop it. 2. **Regression diff (5 of my tests + 18 existing)**: my new `WHERE deleted_at IS NULL` filter breaks every test that builds an ephemeral `agent_ownership` schema without the new column. And my own tests routed through the production `db.connection.get_db_connection()` which reads `DB_PATH` at module-import time — so once `db.connection` is loaded transitively (any earlier test importing `db.agents`), my `monkeypatch.setenv("TRINITY_DB_PATH", ...)` arrives too late. Fixes: - `test_agent_soft_delete.py`: replaced env-var routing with a `tmp_agent_db` fixture that `monkeypatch.setattr`s `db.connection.DB_PATH` directly. Survives whatever order pytest imports things. Also factored repeated setup into the fixture so each test is 4 lines instead of 20. - Added `deleted_at TEXT` column to the ephemeral `agent_ownership` schema in 7 affected test files: test_backlog.py, test_canary_invariants.py, test_file_sharing_mixin.py, test_guardrails.py, test_subscription_auto_switch_pingpong.py, test_watchdog_unit.py, scheduler_tests/conftest.py. The legacy schema in `test_agent_shared_files_migration.py` is intentionally pre-#834 (it tests the migration runner) and stays untouched. Related to #834 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(soft-delete): close scheduler gap + parity test + docs (#834 Phase 1a) Addresses PR #838 review (vybe, CHANGES_REQUESTED): - list_all_enabled_schedules() (backend db.schedules AND the standalone scheduler process) now JOINs agent_ownership and filters deleted_at IS NULL — a soft-deleted agent's enabled schedules stop firing immediately instead of writing a schedule_executions failure row per cron tick until the 180-day purge. - Add tests/unit/test_agent_cleanup_parity.py — the enforcement test agent_cleanup.py's docstring promises. Bidirectional schema↔AGENT_REFS parity + KEEP-policy lock. Stdlib-only loader, real CI gate (no venv skip). Plus a scheduler regression test for the agent-soft-delete gap. - requirements.md: new §32 Agent Soft-Delete & Retention Lifecycle (Phase 1a detailed; 1b/1c noted pending). - architecture.md: agent_ownership.deleted_at + idx_agent_ownership_deleted_at in the schema block; soft-delete purge added to the Cleanup Service row; agent_soft_delete_retention_days (default 180, 0=disabled). - Fix stale "default 30" comment in routers/agents.py (bumped to 180 in 45d99a1). Related to #834 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(parity): adopt sanctioned _STUBBED_MODULE_NAMES pattern (#834) The #834 parity test registers two synthetic modules (trinity_db_schema, trinity_db_agent_cleanup) into sys.modules at import time so @DataClass can resolve cls.__module__ while exec'ing db/agent_cleanup.py. That bare `sys.modules[mod_name] = module` tripped the `lint (sys.modules pollution check)` gate (0 → 1 vs baseline). Fix: add a top-level _STUBBED_MODULE_NAMES list + autouse _restore_sys_modules fixture — the sanctioned self-contained snapshot/restore pattern the lint whole-file-exempts (precedent: tests/unit/test_telegram_webhook_backfill.py). Also stops the synthetic modules leaking into sibling test files in the same pytest session. Parity suite still green (4 passed). Related to #834 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…killer (#586) (#837) * feat(observability): emit [METRIC] drain_outcome on slow-path orphan-killer (#586) Add structured `[METRIC] drain_outcome` log emissions at three sites in `drain_reader_threads` so operators can track the post-fix rate of the slow-path orphan-killer engaging. Fast path stays silent — any emission is operationally meaningful. - subprocess_pgroup.py: surface stuck_initial_count, orphan_kill_count (sentinel -1 when /proc scan timed out), drain_elapsed_ms, and optional leaked_count via three outcome= values: natural, force_close, leaked. orphan_scan_completed gate prevents racing the daemon thread's write to _orphan_result. - tests/unit/test_subprocess_pgroup.py: 2 new tests covering the natural-drain and force-close metric emissions; force-close test guards the bug-class regression site. - docs/TRINITY_COMPATIBLE_AGENT_GUIDE.md: new "Stop hook authoring — release inherited stdout" section with bash/python/node patterns to release the inherited stdout FD before blocking I/O so hooks bypass the slow path entirely. - docs/memory/feature-flows/execution-termination.md: document slow-path drain observability — outcome= taxonomy, fields, fleet audit script, and authoring escape hatch cross-reference. - scripts/586-fleet-check.sh: fleet-wide audit gating Issue #586 close-out — scans Vector agent logs across configurable lookback, exits non-zero on residual "still stuck after Ns" / "no result message after" events. Refs #586 Co-Authored-By: Claude <noreply@anthropic.com> * fix(scripts): slurp JSONL for per-container summary in 586-fleet-check.sh `jq -rc 'group_by(...)' file` on JSONL input fails per-line with "Cannot index string with string 'container'" and exits 5 — `group_by` requires an array, but each line is parsed as a separate object input. Under `set -euo pipefail` this killed the script before it reached the gate at line 38: when residual #586-class events were actually present, the operator saw jq error noise instead of the intended "FAIL: residual #586-class events found — DO NOT close." message. Add `-s` (slurp) so jq collects the JSONL inputs into an array before applying `group_by`. Verified against three fixtures: residual events (exits 1, prints FAIL + per-container summary), empty input (exits 0, prints PASS), and orphan-killer-only events (exits 0, prints PASS). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(scripts): use grep-based gate for residual #586 events in fleet-check Switch the close-out gate from `jq -e 'select(...)'` to `jq -r 'select(...) | .msg' | grep -q .`. The `jq -e` form behaves correctly on jq 1.7.1 (exits 4 on no-match, which bash `if` treats as false), but the grep-based form is version-agnostic and matches the pattern reviewers expected on first read. Verified against three fixtures: match → gate fires (exit 1); other bug-class events but no blockers → gate skipped; empty input → gate skipped. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
…ts (#678) (#797) * fix(executor): salvage telemetry + auto-retry reader-race empty results Issue #678: when claude's stdout reader thread wedges mid-turn (a tool subprocess inherits the stdout fd), the trailing `result` line is lost and `chat_with_agent` returns null cost/context/response while the schedule_executions row is written as FAILED with no telemetry. Recovery pipeline (agent-server side): - `_classify_empty_result` now returns a structured dict body (message + sanitized partial metadata + raw_message_count) so the backend can salvage what telemetry was captured before the race - `_recover_metadata_from_jsonl` back-fills cost_usd, duration_ms, num_turns, per-call usage, model_name from the on-disk JSONL — Claude Code writes turns to the JSONL via a side channel independent of stdout - `_attempt_empty_result_recovery` shared helper wires JSONL metadata back-fill → text recovery from response_parts → text recovery from JSONL → structured 502 dict body, used by both sync and async paths - session_id_fallback (the UUID we passed via --session-id) closes the recovery gap when the race wedges before claude echoes its session_id - Long-running headless tasks (timeout > 600s) auto-enable JSONL persistence so recovery can fire; short fan-out stays disk-cheap. Session cleanup service reaps the stale JSONLs on its existing sweep - jsonl_recovery hardened: safe session_id regex + resolve()/is_relative_to containment so a corrupted stdout line can't drive the reader outside the projects dir - stream_parser captures model_name from assistant.message.model so it survives the reader-race even when the trailing result line is lost Auto-retry (backend side): - task_execution_service detects the reader-race signature on 502 dict bodies and fires one in-line retry with the same execution_id when num_turns < 5, raw_message_count == 0, parse_failure_count == 0 - retry caps timeout at 300s on both sides so a 30-min task that ate 28 min before failing doesn't get another 30 min on top - CB re-check between attempts; previous-attempt cost rolled into the terminal cost write so spend isn't silently absorbed - audit log `auto_retry` event (fire-and-forget, non-blocking) Salvage path (backend HTTPError handler): - routers/chat.py + task_execution_service.py both parse the structured dict detail and update_execution_status with salvaged cost/context/ context_max instead of null-everything - `_compute_context_used` shared helper keeps success and salvage paths computing context_used the same way Schema: - migration 59: schedule_executions.retry_count INTEGER DEFAULT 0 - ScheduleExecution.retry_count surfaces through get_execution_result - update_execution_status retry_count is COALESCE-preserved so cleanup and scheduler paths don't accidentally zero it Tests: 3 new files (auto_retry signature, dict body shape, JSONL metadata recovery) + dict-body migration in existing classification tests + persist-session flag now combines persist_session OR timeout threshold. Closes #678 Co-Authored-By: Claude <noreply@anthropic.com> * docs(security): add CSO branch-diff audits for #678 work Two daily diff audits run during issue #678 development: - 2026-05-11: scoped to the initial recovery pipeline + auto-retry - 2026-05-12: post mid-audit fix on jsonl_recovery (shape whitelist + is_relative_to containment, 12 parametrized tests for hostile session_id shapes) Refs #678 Co-Authored-By: Claude <noreply@anthropic.com> * fix(tests): drop redundant agent_server sys.modules stubs (#678) `tests/unit/conftest.py:_preload_real_agent_server()` already registers `agent_server` as a namespace package globally before any unit test collection — the per-file `if "agent_server" not in sys.modules: ...` blocks in the two new #678 test files are dead code that just trip `tests/lint_sys_modules.py`. Removes the dead block from `test_error_classifier_dict_body.py` and `test_jsonl_metadata_recovery.py`, plus the now-unused `sys`/`types` imports and supporting path constants. Keeps `from pathlib import Path` in the JSONL-recovery file (used by `_write_jsonl(tmp_path: Path, ...)`) and the agent_server imports themselves (resolve via the conftest namespace shim). All 32 tests in the two files still pass; the lint stops growing the `tests/lint_sys_modules_baseline.txt` baseline by 2. Refs #678 Co-Authored-By: Claude <noreply@anthropic.com> * docs(architecture): document #678 retry_count + auto-retry + headless JSONL reaping Three additive doc edits matching what shipped in the executor recovery work but wasn't reflected in `docs/memory/architecture.md`: 1. `schedule_executions` DDL block now lists the `retry_count INTEGER DEFAULT 0` column added by migration 59. 2. `task_execution_service.py` service-row gains a clause describing the in-line auto-retry (502 dict body / num_turns < 5 / raw_message_count == 0 / parse_failure_count == 0; capped at 300s, previous-attempt cost rolled into the terminal write). 3. `session_cleanup_service` Background-Services row gains a clause noting that headless-task JSONLs (timeout > 600s, auto-enabled by `agent_server/services/jsonl_recovery.py`) are reaped by the same sweep — they aren't in `agent_sessions` so they fall out of the keep set and the existing 1h age guard + 6h cycle removes them. No new component descriptions for the agent-server internal services themselves — `architecture.md` documents the agent-server surface, not its internal services. Refs #678 Co-Authored-By: Claude <noreply@anthropic.com> * fix(tests): restore unit-suite sys.modules between tests (#678) The parent tests/conftest.py #762 baseline-restore never loads for the unit suite because tests/unit/pytest.ini makes tests/unit/ the pytest rootdir. That blind spot let collection-time sys.modules stubs leak across files under pytest-randomly, manifesting on PR #797 as three test_voice_auth regressions (close_code 4001 instead of 4003/accept) across all three CI seeds. Adds an autouse mirror fixture in tests/unit/conftest.py that captures config + database in the baseline, restores before+after each test, and pops non-baseline keys matching a narrow prefix policy. Uses a per-process TRINITY_DB_PATH so two concurrent local pytest invocations don't race on the eager DB init. Converts tests/unit/test_cleanup_unreachable_orphan.py to the lint-exempt _STUBBED_MODULE_NAMES + _restore_sys_modules helper-pair pattern (tests/lint_sys_modules.py:96-115). Drops the now-dead `database` stub since the conftest preload supersedes it. Net: lint (sys.modules pollution check) passes; test_voice_auth's three ownership-gate tests go green across seeds 12345/67890/99999; only the two pre-existing test_orphaned_execution_recovery failures remain (both in CI base baseline). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tests): add sanitize_dict to credential_sanitizer stub (#678) test_cb_probe_execution_close.py stubs utils.credential_sanitizer in sys.modules to keep the unit tests self-contained, but the stub was missing sanitize_dict. The #678 salvage path in src/backend/services/task_execution_service.py:35 added `from utils.credential_sanitizer import sanitize_dict, ...`, so the test module now fails to import the SUT with: ImportError: cannot import name 'sanitize_dict' from 'utils.credential_sanitizer' That import error is what the 03:07 BST full-suite run mis-attributed to a MagicMock-vs-AsyncMock mismatch. The 7 cluster-A failures actually all share the same ImportError root cause; promoting the circuit mocks would not have helped (production calls circuit.allow_request() synchronously). The one-line stub addition makes all 10 tests in the file green. Verified locally with ADMIN_PASSWORD set: 10 passed, 14 warnings in 0.48s Refs #678 * fix(tests): defeat cross-file sanitizer pollution in cluster-A (#678) The previous sanitize_dict stub commit (b4cc5dde) was correct in isolation but didn't survive the full-suite run. test_validation.py overwrites sys.modules["utils.credential_sanitizer"] with an incomplete stub at module-collection time: _sanitizer_mod = types.ModuleType("utils.credential_sanitizer") _sanitizer_mod.sanitize_text = lambda x: x # only one fn sys.modules["utils.credential_sanitizer"] = _sanitizer_mod Our file used sys.modules.setdefault(...) (a no-op once polluted), so the incomplete stub wins. When the test re-imports services.task_execution_service, the `from utils.credential_sanitizer import sanitize_dict, ...` line raises ImportError. That's the real source of all 7 cluster-A failures the 03:07 BST run misdiagnosed as a MagicMock-vs-AsyncMock issue — production code calls circuit.allow_request() synchronously, so the mock type was never the problem. In parallel, services.task_execution_service itself can be stubbed as a MagicMock by other test files. tests/conftest.py's _SYS_MODULES_BASELINE captures None for that key (not preloaded), so its autouse restore is a no-op. The MagicMock persists; the re-import returns a MagicMock class; svc.execute_task is not awaitable. Defense: a new autouse fixture re-asserts our complete sanitizer stub and evicts services.task_execution_service before every test in this file, so the test's import statement loads the real class against our complete stub. Verified: - test_cb_probe_execution_close.py alone: 10 passed - test_validation.py + test_cb_probe_execution_close.py (in that deterministic order, which previously reproduced the pollution): 29 passed, 3 skipped Refs #678 * docs(test-runs): comprehensive after-fix test plan report for #797 (#678) Three data points: - Control (dev, paper, excl. slow): ~10 pre-existing failures - Treatment 1 (PR unfixed, full): 3465 pass / 24 fail (03:07 BST) - Treatment 2 (PR + cluster-A fix, excl. slow): 3457 / 12 / 127 Net-new failures attributable to PR #797: 0. Cluster A (7 failures) cleared by commit 3b0653b0 — the 03:07 root- cause label was wrong (it blamed MagicMock vs AsyncMock, but production calls circuit.allow_request() synchronously). Actual cause was cross- file sys.modules pollution: test_validation.py overwrites utils.credential_sanitizer with an incomplete stub, defeating our setdefault. The autouse fixture re-asserts our complete stub and evicts services.task_execution_service so each test re-imports the real class. Verified 10/10 isolated and 29 passed when test_validation is collected first. The remaining 12 failures are all pre-existing on dev or seed-dependent flakes in unrelated test files (clusters B, D, E, G + one unit flake). Live verification on the running macau stack confirmed: - Migration 59 applied (retry_count INTEGER DEFAULT 0) - 5 recent rows show retry_count=0 (happy-path correct) - _is_reader_race_signature gate exercises positive + negative cases correctly against deployed code - session_cleanup_service documents the #678 headless JSONL reap Operational note: agent base image built 2026-05-09 pre-dates #678 agent-side commits. Before agent-side fixes (structured error body, JSONL metadata salvage) are live in production agent containers, run ./scripts/deploy/build-base-image.sh and recreate agents. Refs #678, PR #797 * docs(test-runs): live-DB acceptance evidence for #678 reviewer items Documents the two acceptance criteria from PR #797's reviewer comment: 1. Trigger a long-running headless task, kill its stdout reader, verify the failure row carries cost + recovered_from_jsonl=True instead of null telemetry. 2. Confirm retry_count=1 appears on the row when the reader-race signature fires and the retry succeeds. The reader-race itself is non-deterministic (fd inheritance timing), so the script validates the recovery pipeline by mocking only agent_post_with_retry to return the exact structured 502 body that error_classifier._classify_empty_result emits. Everything else runs unmodified against the live trinity.db inside trinity-backend. Results — both PASS: Acceptance #2 (retry-success): Execution ID: 6AmUgSbpF-dMU0kL-xRH2A status=success, retry_count=1, cost=$0.08 ($0.05 failed first attempt rolled in + $0.03 retry) Acceptance #1 (salvage on double-failure): Execution ID: Q06wdBqFtPegQlGzqIVvWw status=failed, retry_count=1, cost=$0.10, context_used=100 (would have been all NULL before #678) Plus 63/63 supporting unit tests pass across the four #678 test files: test_jsonl_metadata_recovery.py (24 — covers JSONL salvage + 12 hostile session_id shapes) test_auto_retry_reader_race.py (14 — gate semantics) test_empty_result_classification.py (7 — dict body shape) test_error_classifier_dict_body.py (18 — sanitization invariants) Refs #678, PR #797 * fix(tests): switch _restore_complete_stubs to monkeypatch (#678) The autouse fixture introduced in 170fc23 to defeat cross-file sanitizer pollution did bare sys.modules writes, which the Issue #762 lint (tests/lint_sys_modules.py) bans for any *new* violations in an already-baselined file. Rewriting via monkeypatch.setitem / monkeypatch.delitem brings the file back to its 5-line baseline and adds auto-revert on teardown. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tests): read detail['message'] from 502 dict body (#678) PR #797 (#678) changed _classify_empty_result's HTTP 502 detail from a plain string to a structured dict carrying salvage telemetry. The test added by #813 (test_clean_exit_but_missing_cost_falls_to_empty_result_classifier) still called .lower() on the raw detail and now hits AttributeError. Mirror the canonical pattern from tests/unit/test_empty_result_classification.py: assert detail is a dict, then read the human-readable text out of detail['message'] before doing substring checks. No production behavior change — only the assertion plumbing was stale. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(feature-flows): sync task-execution-service + parallel-headless-execution for #678 PR #797 lands a coupled refactor + behavioral change pair the flow docs needed to capture: - task-execution-service.md: new top-of-doc blockquote for the auto-retry gate on reader-race signature, structured 502 dict body salvage path (cost/context written onto FAILED rows instead of null), shared _compute_context_used helper, and migration 59's retry_count column. - parallel-headless-execution.md: new 2026-05-13 row in Revision History documenting the claude_code.py decomposition (956+465+461+ 448 LOC across new headless_executor, error_classifier, jsonl_recovery, stream_parser modules), dict-body shape evolution, two-step JSONL metadata + text recovery, auto-enable JSONL persistence for timeout > 600s, and path-containment hardening in jsonl_recovery. Closes the optional polish item flagged by /validate-pr (#797). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(test-runs): add #797 full-suite run report 2026-05-12 (#678) Captures the full pytest run (non-unit + unit halves) at HEAD 1b8651e, before the rebase that picked up #833's baseline fix. 2026 pass / 24 fail / 163 skip on the non-unit half in 41:49. Refs #678 * test(acceptance): add live-stack harness for #678 acceptance criteria One-off harness that runs against live trinity.db inside trinity-backend. Mocks only \`agent_post_with_retry\` so the real DB write path, capacity manager, activity service, and audit log fire as they would in production. Verifies the two acceptance criteria: 1. Failure rows carry cost + context (salvaged from the 502 dict body's partial metadata) instead of null when the reader-race signature fires and the retry also fails. 2. \`retry_count=1\` appears on the row when the reader-race signature fires and the retry succeeds. Refs #678 --------- Co-authored-by: Claude <noreply@anthropic.com>
* feat(files): create folder in File Manager (#37) Adds a "New Folder" capability to the per-agent File Manager (Files tab). Three surfaces, mirroring the existing update/delete file path: - Agent-server: POST /api/files/mkdir — workspace-confined, rejects edit-protected paths (.trinity/.git/etc.), 409 if target exists, creates intermediate parents. - Backend: POST /api/agents/{name}/files/mkdir → create_agent_folder_logic (access check + _is_user_writable_path deny-list + container-running guard, proxies to agent-server). Propagates 403/409 from the agent. - Frontend: New Folder button + modal in FilesPanel.vue; creates inside the selected directory when one is selected, else workspace root; supports nested paths via "/". New createAgentFolder store action. Tests: TestCreateFolder in tests/test_agent_files.py (create+list, duplicate→409, protected→403, unknown agent→404, auth→401). Docs: requirements.md §13.1 + architecture.md endpoint tables. Verification: all changed Python py_compile clean; frontend vite build clean; new tests collect (5). Integration tests require a live stack (self-skip on 503) and run in CI. Related to #37 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(files): resolved-path containment in mkdir (CodeQL py/path-injection) CI CodeQL flagged "uncontrolled data used in path expression" (CWE-022) on create_folder. The str(requested_path).startswith(str(allowed_base)) guard is not a recognized CodeQL barrier and is genuinely weak: it has a sibling-prefix bypass ("/home/developer-x".startswith("/home/developer") is True). Replace with Path.is_relative_to() against a resolved base. .resolve() collapses any "../" before the check, so this is both a correct containment check and the path-traversal barrier. Verified against traversal, sibling-prefix, and absolute-escape cases. Scope: only the new create_folder endpoint. update_file/delete_file in the same file use the same legacy startswith pattern but are unchanged here (not in #37's scope, not flagged on this PR's diff) — noted for a separate follow-up. Related to #37 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The chat path (claude_code.py) was missing the _classify_signal_exit call that was added to the headless path (headless_executor.py) for Issue #516. When a SIGKILL terminates the claude subprocess at 0 turns (cgroup OOM, host SIGKILL, watchdog cancel), the chat handler would fall straight into _diagnose_exit_failure, which returns "Subscription token may be expired or revoked. Generate a new one with 'claude setup-token'." even when no auth signal was observed. This misclassification: - Misleads operators into chasing token regeneration when the actual cause is OOM / timeout / external kill - Pollutes the SUB-003 auto-switch trigger pattern matcher (which reads the error string), causing spurious subscription rotations on agents whose subscriptions are provably healthy - Burns the auto-switch 2-hour skip-list slot on phantom auth failures Fix mirrors the existing pattern in headless_executor.py:683 — call _classify_signal_exit first, fall through to _diagnose_exit_failure only for non-signal exits. No new logic; the classifier already produces the honest "Execution terminated by SIGKILL after N tool calls / N turns" message. Adds a structural regression test (parametrized over both files) that pins the call ordering — _classify_signal_exit must appear before _diagnose_exit_failure in both call sites, otherwise the auth-fallback heuristic re-introduces the misclassification. Deployment: requires base image rebuild + agent restart for the fix to take effect on running agents (per CLAUDE.md note #7). Out of scope: Fix 2 (gate auto-switch on observed wire 401/403/429) and Fix 3 (cgroup OOM event reading) — both flagged in #906 as follow-up improvements. Fixes #906 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…get (RC-1 + RC-2 + RC-3) (#907) * fix(#904): SIGKILL/OOM no longer misclassified as auth failure Issue #904: when an agent container's cgroup OOM-killer fires (or any external SIGKILL / SIGTERM / schedule timeout hits the claude subprocess), the resulting failure was misreported as "Subscription token may be expired or revoked." That false signal then tripped SUB-003 substring matchers in `services/subscription_auto_switch.is_auth_failure` and `src/scheduler/service._is_auth_failure`, firing a futile subscription auto-switch and burning the 2-hour skip-list slot for the alternative — exactly when no auto-switch can help (the new sub has the same memory limit too). Three layered fixes, smallest scope per the issue's "RC-2 + RC-3 first": 1. **Wire `_classify_signal_exit` into the chat path.** The headless executor was already correct (#516) but `claude_code.py:450` did not call the signal classifier — every OOM kill on `/api/chat` fell through to `_diagnose_exit_failure`'s "token expired" diagnostic. Now the chat path classifies SIGKILL/SIGTERM/SIGINT first and raises 504 with the explicit "Execution terminated by SIGKILL after N tool calls / M turns" detail, exactly like the headless path. 2. **Reword the diagnostic surfaces that fed the false positive.** - `headless_executor.py:707` zero-tokens fallback no longer says "possible authentication issue". The dedicated `is_auth_failure` 503 raised on a confirmed auth-pattern match a few lines above remains the only path that surfaces the auth phrasing. - `error_classifier._diagnose_exit_failure` (the OAuth-without-API-key branch, line 155) no longer returns the bare "Subscription token may be expired or revoked. Generate a new one with 'claude setup-token'." The new wording lists OOM kill / schedule timeout / container restart as the most-likely causes and carefully avoids any phrase that `_is_auth_failure_message` matches — so the result can't loop back through `headless_executor`'s auth detection. 3. **Negative markers on `is_auth_failure`.** Even if a future wording regresses, the auth-class trigger now short-circuits to False when the error message contains any unambiguous signal-kill / OOM / timeout marker (`sigkill`, `sigterm`, `exit code -9`, `exit code 137`, `out of memory`, `oom`, `memory cgroup`, `terminated by`, `killed by`, …). Same list mirrored in `src/scheduler/service.py` since the scheduler runs in a separate container and can't import from `services/subscription_auto_switch.py`. Tests: `tests/unit/test_904_sigkill_no_false_auth.py` (14 tests) — backend `is_auth_failure` + scheduler `_is_auth_failure` negative markers, `_diagnose_exit_failure` OAuth-only branch no longer trips `_is_auth_failure_message`, `_classify_signal_exit` correctness for negative codes and shell-encoded 137, static wire-up assertion that the chat path calls the classifier BEFORE the diagnose fallback. Out of scope for this PR (issue lists them; pursue separately): - RC-1: per-agent backend in-flight call limit (worker saturation protection — architectural change, second PR). - RC-4: cgroup `memory.events` read for explicit OOM observability (additive feature, second PR). Closes part of #904 (RC-2 + RC-3 surfaces). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tests): adopt _STUBBED_MODULE_NAMES pattern for #904 test file CI lint flagged the bare `sys.modules[name] = stub` calls in `_load_backend_is_auth_failure` and `_load_scheduler_is_auth_failure`. The stubs are necessary — both `database` and `apscheduler.*` would pull in heavy initialisation (real `DatabaseManager()`, real APScheduler executors) if not stubbed before importlib's `exec_module`, blowing up the pure-function test. Adopt the sanctioned `_STUBBED_MODULE_NAMES` + autouse `_restore_sys_modules` pattern (precedent: `tests/unit/test_agent_cleanup_parity.py` from PR #765ce6) which `tests/lint_sys_modules.py:_has_stubbed_module_names_helper` explicitly whitelists. Now snapshots + restores `database`, `db_models`, and the `apscheduler.*` subtree per test. Related to #904 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(#904): per-agent + global semaphore on backend agent HTTP calls (RC-1) Issue #904 RC-1 — UI freeze on slow agent. Backend's `task_execution_service.agent_post_with_retry` had no fan-out bound, so a single misbehaving agent's 11.5-min HTTP call could leave many backend coroutines `await`ing on `httpx.post` while each emitted periodic synchronous `sqlite3` calls (`db/connection.py:18` — `sqlite3.connect(timeout=30.0)`). Sync DB inside async coroutines stalls the event loop momentarily; with enough concurrent long-runners + writes to the same SQLite file, the writer-lock contention drove the Docker healthcheck past its 10s ceiling and the operator dashboard's parallel API fan-out queued until the offending agent was restarted by hand. This PR adds two layered semaphores around outbound agent calls, keeping the call shape (await on httpx) untouched: 1. **Per-agent semaphore**, sized to the agent's `max_parallel_tasks` (default 3 on lookup miss). Bounds fan-out per agent so one bad citizen can't dominate. 2. **Global semaphore** sized to `BACKEND_AGENT_CALL_LIMIT` env (default 8). Caps total concurrent outbound calls — the backend keeps spare async capacity for dashboard / healthcheck even when every agent is mid-call. Acquire-with-timeout (`BACKEND_AGENT_CALL_QUEUE_TIMEOUT_S`, default 30s) raises `BackendAgentCallBudgetExhausted`; both `task_execution_service.execute_task` and `routers/chat.py` get dedicated except-branches that mark the execution FAILED and (in the chat router) return HTTP 503 with the budget message. SUB-003 auto-switch does NOT fire on this path — the rejection is local to the backend, the subscription is unrelated. Files: - `src/backend/services/agent_call_limiter.py` (new) — primitives: `acquire_agent_call_slot`, `BackendAgentCallBudgetExhausted`, `_reset_for_testing` test hook - `src/backend/services/task_execution_service.py` — wrap each connect-retry attempt in `agent_post_with_retry` with the slot context manager; add dedicated except in `execute_task` - `src/backend/routers/chat.py` — same dedicated except, 503 to the caller - `docker-compose.yml` — pass both env vars to backend (commented defaults: 8 / 30) - `docs/memory/requirements.md` — §10.4.2 explaining behavior + the explicit out-of-scope note (sync→async DB is a separate refactor) Verified live on local instance with `BACKEND_AGENT_CALL_LIMIT=2` and a sleeper-shim agent (replaces `/usr/bin/claude` with a `sleep 300`): 3 concurrent /api/chat calls; 2 acquired immediately, the 3rd returned HTTP 503 in 2176ms with detail "Backend call budget exhausted for rc1-test after 2001ms (agent_cap=3, global_cap=2)". Dashboard /health stayed responsive. Out of scope (separate follow-up): - True sync→async-DB migration (`run_in_executor`-wrapped sqlite3 or `aiosqlite`). The semaphore reduces contention but doesn't eliminate it. - RC-4 cgroup OOM observability. Related to #904 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(#904 RC-1): default queue timeout 3600s — preserve "eventually succeeds" + break deadlocks Review feedback on #907: the original 30s queue-acquire timeout would turn calls that previously eventually succeeded into new HTTP 503s under sustained backlog. That's a regression we don't want. Trade-off matrix: * No timeout (wait forever): zero false 503s but agent-to-agent chat chains (chat_with_agent MCP tool, X→Y→Z collaborations) can deadlock when concurrent chain depth exceeds the global semaphore. Each chain holds a slot for its outer caller while waiting on the next hop, which itself wants a slot. With cap=8 and >8 deep parallel chains the system hangs forever. * Short timeout (30s): no deadlocks but every long-tail call near the cap risks 503. * Long timeout (3600s = platform max execution_timeout): pre-#904 worst-case wall-clock was the agent's ~610s HTTP timeout, so 3600s leaves a 6x margin for any task that would have succeeded; deadlocks surface as 503s within an hour and the queue drains. ← chosen. Change: * Default `BACKEND_AGENT_CALL_QUEUE_TIMEOUT_S` 30s → 3600s. * Setting the env var to 0 disables the cap entirely (opt-in "wait forever" — accepts deadlock risk for zero false 503s). Implemented as a fast path that skips `asyncio.wait_for` when the timeout is 0, plus a one-shot "queued > 5s" warning so operators see sustained pressure in Vector logs without spamming. * Test added: `test_default_timeout_is_one_hour` asserts the production default; `test_timeout_zero_opt_in_waits_indefinitely` pins the opt-in behavior. * requirements.md + docker-compose.yml updated to explain both the new default and the deadlock-safety-valve rationale. Related to #904 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…e 1b) (#839) * feat(soft-delete): agent_schedules soft-delete + retention (#834 Phase 1b) Extends the #834 Phase 1a pattern to `agent_schedules`. Stacked on `feature/834-soft-delete-agents` (PR #838). Both PRs merge cleanly in either order — schema migration + retention sweep are additive. What changes - `DELETE /api/agents/{name}/schedules/{id}` marks `deleted_at = NOW` instead of hard-deleting. The schedule disappears from the scheduler's firing list immediately (the scheduler service's poll filters `enabled = 1 AND deleted_at IS NULL`), but the row + every `schedule_executions` child stay for the retention window so an operator can recover them by hand via `UPDATE agent_schedules SET deleted_at = NULL WHERE id = '...'`. - The retention sweep in `cleanup_service.py` hard-purges schedules past `schedule_soft_delete_retention_days` (default 30 days — shorter than the agent window since schedules are higher-churn). Each purge also wipes the schedule's `schedule_executions` rows, matching the previous hard-delete semantics. - Webhook tokens on soft-deleted schedules stop resolving — the `get_schedule_by_webhook_token` lookup filters `deleted_at IS NULL` too, so a leaked URL is invalidated the moment the schedule is marked deleted, not only at retention end. Where - Schema: `agent_schedules.deleted_at TEXT`, partial index `WHERE deleted_at IS NOT NULL`. Versioned migration in `db/migrations.py`. - `db/schedules.py`: - `delete_schedule()` flipped from DELETE to soft-delete. Idempotent on re-delete with permission. - `purge_schedule()` added — refuses live rows; called by the cleanup sweep. - `find_soft_deleted_schedules_past_retention()` drives the sweep. - 7 read sites filtered: `get_schedule`, `list_agent_schedules`, `list_all_enabled_schedules`, `list_all_disabled_schedules`, `list_all_schedules`, `get_schedule_by_webhook_token`, `get_webhook_status`, `get_all_agents_schedule_counts`. - `src/scheduler/database.py` (separate scheduler service process): same 4 read sites filtered. Without this the dedicated scheduler process would keep firing soft-deleted schedules. - `services/cleanup_service.py`: new sweep block calling `purge_schedule()` for eligible IDs, bounded by the existing 5000- row/cycle cap. - `services/settings_service.py`: new `schedule_soft_delete_retention_days` (default 30). Out of scope (intentional) - `delete_agent_schedules(agent_name)` (mass-delete) was left as a hard-delete. Its only caller path was removed in Phase 1a's `delete_agent_endpoint` rewrite; the function is now dormant and the cascade-on-purge path uses #816's `cascade_delete` instead. Refactoring the dead helper is out of scope. - Soft-delete does not interact with `schedule_executions` rows — they're billing-relevant (subscription_id rollup) and #772's existing retention sweep ages them out independently. Live verification on the running stack (uvicorn auto-reload picked up every change): 1. CREATE agent + schedule: 1 row in list ✓ 2. DELETE /api/agents/X/schedules/Y → 204 3. LIST after delete: 0 rows (filter working) ✓ 4. DB row still present, deleted_at populated ✓ 5. db.purge_schedule(id) → True, row + executions gone ✓ Related to #834. Phase 1b only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(schedules): idempotent delete_schedule + complete test users schema (#834 Phase 1b) Found running the targeted test batch on the rebased stack. Production bug: `delete_schedule` ran its owner/admin permission check against `self.get_schedule()`, which Phase 1b changed to filter `deleted_at IS NULL`. So a retry on an already-soft-deleted schedule got None back, fell through to `return False`, and the router (schedules.py:330) turned that False into `403 "Cannot delete schedule - access denied"` — a misleading forbidden error shown to the legitimate owner who simply double-clicked or retried the delete. Fix: read `owner_id, deleted_at` directly (unfiltered) for the permission gate so a soft-deleted schedule's owner is still verifiable. Behavior now: - row absent → False (router 403; nothing to delete) - caller not owner/admin → False (router 403; correct) - already soft-deleted → True (idempotent; router 204) - live → soft-delete, True Matches the idempotent shape of #834 Phase 1a's `delete_agent_ownership`. Test fixture bug: `test_schedule_soft_delete.py`'s ephemeral `users` table only had (id, username, email, role). `delete_schedule` → `UserOperations.get_user_by_username` selects the full `_USER_COLUMNS` set (password_hash, auth0_sub, name, picture, created_at, updated_at, last_login), so the lookup raised `OperationalError: no such column: password_hash`. Expanded the fixture's users DDL to the full column set. The idempotent-retry test now asserts the corrected True (was masking the 403 bug). All 29 tests across the three soft-delete files pass. Related to #834 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tests): add deleted_at to scheduler conftest agent_schedules fixture (#834 Phase 1b) Phase 1b added `WHERE deleted_at IS NULL` to the scheduler service's agent_schedules read paths (src/scheduler/database.py: get_schedule, list_all_enabled_schedules, list_all_schedules, list_agent_schedules) but the scheduler test fixture's ephemeral agent_schedules DDL didn't get the column — 5 tests in scheduler_tests/test_database.py failed with `OperationalError: no such column: deleted_at`. Fixture-only change. All 10 scheduler_tests/test_database.py tests pass. Related to #834 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(soft-delete): docs + merged firing-query filter (#834 Phase 1b) Addresses PR #839 review (vybe, CHANGES_REQUESTED) + rebase onto the updated #838 (Phase 1a): - list_all_enabled_schedules() (backend + standalone scheduler) now filters BOTH s.deleted_at IS NULL (Phase 1b, this PR) AND ao.deleted_at IS NULL via the agent_ownership JOIN (Phase 1a, #838). A schedule is skipped if either it or its agent is soft-deleted. - test_schedule_soft_delete.py: ephemeral schema now creates agent_ownership + seeds a live owner row per agent so the merged firing-query JOIN resolves. - architecture.md: agent_schedules.deleted_at + idx_agent_schedules_deleted_at in the schema block; schedule soft-delete prose; Phase 1b purge added to the Cleanup Service row with schedule_soft_delete_retention_days (default 30, 0=disabled). - requirements.md: §32.2 fleshed out (read paths, idempotency, retention, execution-row ownership pre-purge vs at-purge, storage). PR body corrected: the schedule_executions "Out of scope" line now accurately states #772 owns them pre-purge while purge_schedule() cascades the delete at purge — consistent with prior hard-delete behavior (reviewer's option a). Related to #834 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tests): defend test_schedule_soft_delete against db.schedules stub leak (#834 Phase 1b) CI regression-diff (PR #839) showed all 11 soft-delete tests failing with `no such table: agent_schedules` / `users` under pytest-randomly seeds 67890 and 99999 — passing under 12345. Diagnosed via head-99999 JUnit XML. Root cause: sibling tests (`test_execution_retention_prune.py`, `test_audit_retention_prune.py`, `test_session_operations.py`) install freshly-loaded modules into `sys.modules["db.schedules"]` / `sys.modules["db.monitoring"]` via plain assignment (no monkeypatch, no _STUBBED_MODULE_NAMES restore). Those stubs bind `get_db_connection` at exec_module time to the polluter's tmp-DB `_erp_db_connection`. After the polluter finishes, monkeypatch restores `db.connection` but the stale `db.schedules` stub remains. When `test_schedule_soft_delete.py` later imports `db.schedules`, it gets the stale stub whose `get_db_connection()` points at a deleted tmp file — hence the missing-table errors. The `monkeypatch.setattr` on the real `db.connection.DB_PATH` is bypassed entirely because the stub uses a different connection module. Fix: adopt the sanctioned `_STUBBED_MODULE_NAMES` + autouse `_restore_sys_modules` pattern (precedent: `tests/unit/test_agent_cleanup_parity.py`, `tests/unit/test_telegram_webhook_backfill.py`) — snapshot, pop on setup so imports re-resolve fresh, restore on teardown. List covers `db.schedules`, `db.users`, `db.agents`, `db.monitoring`. The lint-sys-modules check explicitly whitelists files exhibiting both `_STUBBED_MODULE_NAMES` and `_restore_sys_modules` (`tests/lint_sys_modules.py:100`), so no baseline change needed. Related to #834 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tests): force-reload db.connection per test (#834 Phase 1b) Follow-up to the previous test-pollution fix. The autouse `_restore_sys_modules` pops the consumer modules (`db.schedules`, `db.users`, `db.agents`, `db.monitoring`) so they re-bind fresh — which removed 8 of the 11 seed-pollution failures on CI. The remaining 3 (all read-path tests under seed 67890) showed empty result sets, indicating `schedule_ops` was opening a *different* SQLite file than `_seed_schedule` wrote to. Root cause: when a sibling test (e.g. `test_session_operations`, `test_file_sharing_mixin`) installs a freshly-loaded `db.connection` into `sys.modules`, our `monkeypatch.setattr(connection_mod, "DB_PATH", ...)` patched the wrong module object on some seed orderings — the copy then re-imported by `db.schedules` saw the polluter's path instead of our tmp file. Fix: in `tmp_schedule_db`, layer three defenses: 1. `monkeypatch.setenv("TRINITY_DB_PATH", ...)` so any fresh `db.connection` import reads our tmp file as the module-level `DB_PATH`; 2. `monkeypatch.delitem(sys.modules, "db.connection", raising=False)` so the next import IS a fresh load against (1) — auto-restored on teardown so we don't pollute either; 3. retain the `monkeypatch.setattr` belt for the case where the fresh load somehow picks up a different env value. Related to #834 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(soft-delete): admin recovery endpoints for agents + schedules (#834 Phase 1c) Closes the user-facing side of the soft-delete story. Pre-#834 recovery was a direct-DB `UPDATE deleted_at = NULL` — unauditable, required shell access, soft-deleted set wasn't surfaced anywhere. Endpoints (admin-only, all audit-logged): GET /api/admin/soft-deleted/agents list soft-deleted agents with `deleted_at` + `purge_eta` (= deleted_at + agent_soft_delete_retention_days) POST /api/admin/soft-deleted/agents/{name}/recover clear deleted_at; 404 if not in the soft-deleted set GET /api/admin/soft-deleted/schedules[?agent_name=...] list soft-deleted schedules; purge_eta from schedule_soft_delete_retention_days POST /api/admin/soft-deleted/schedules/{id}/recover same shape as the agent variant Recovery is metadata-only: flipping deleted_at back to NULL makes the row reappear via all the user-facing read paths Phase 1a+1b filtered. For agents, the container is NOT recreated automatically; the operator does that explicitly via POST /api/agents/{name}/start. The preserved workspace volume keeps the agent's files intact. DB helpers (mirror Phase 1a/1b shape): - AgentOperations.recover_agent_ownership / list_soft_deleted_agents - ScheduleOperations.recover_schedule / list_soft_deleted_schedules (latter takes optional agent_name to scope per agent) All four refuse live rows (returns False / 404). All return False for nonexistent ids — admin endpoint translates to 404. Stacked on #839 (Phase 1b schedules). Stack: #838 (1a) → #839 (1b) → this (1c). Merge order matters because the schedule helpers live on Phase 1b's branch. Live verification on the running stack: 1. CREATE agent + schedule, soft-delete both 2. GET /api/admin/soft-deleted/agents → match found, purge_eta=2026-11-10 (180d from now) 3. GET /api/admin/soft-deleted/schedules?agent_name=X → 1 row, purge_eta=2026-06-13 (30d from now) 4. POST recover agent → 200, deleted_at cleared in DB ✓ 5. POST recover schedule → 200, deleted_at cleared ✓ 6. POST recover on now-live agent → 404 (correct refusal) ✓ 10 unit tests in test_admin_recovery.py cover: recover/list/scope, refuse-live-row, refuse-nonexistent, newest-first ordering, limit handling, agent_name scoping. Related to #834. Phase 1c. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(recover): make recovered agents visible + honest message (#834 Phase 1c) Smoke-testing the rebased stack on the live instance surfaced a recovery dead-end: 1. `GET /api/agents/{name}` 404'd after a successful recover. The handler resolves the agent via `get_agent_by_name` (Docker-backed); soft-delete removed the container and recovery is metadata-only, so there's no container → 404. The operator literally could not see the agent they just recovered, let alone act on it. Recovery was pointless from the UI's perspective. Fix: when `get_agent_by_name` returns None but a live agent_ownership row exists (the auth dependency already proved it does — `get_agent_owner` filters `deleted_at IS NULL`), synthesize a DB-backed `status=stopped, needs_start=true` representation so the agent lists and the UI can offer a Start affordance. 2. The recover endpoint's success message claimed "POST /start to bring it back online" — but Start also 404s on a container-less agent (it too requires an existing container). Container recreate from the preserved workspace volume is the explicitly-deferred #834 Phase 2. Rewrote the message to be accurate: recovery restores all *relational* state (the hard-to-recreate part that #834 exists to protect); re-running the agent needs Phase 2. Added `needs_container_recreate: true` to the response. What recovery delivers today (correct + tested): the agent_ownership row, chat history, schedules, sharing, permissions, credentials config — every relational artifact — comes back intact and visible. What it doesn't (Phase 2): a runnable container. Live-verified on the running stack: create → soft-delete (GET 404) → recover (200) → GET now 200 with needs_start=true → agent visible. Start still 404s by design until Phase 2; message + flag now say so. Related to #834 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(recover): centralize models + cap limit + docs (#834 Phase 1c) Addresses PR #840 review (vybe, CHANGES_REQUESTED); rebased onto the updated #839/#838. - Move SoftDeletedAgent / SoftDeletedSchedule from routers/admin_recovery.py to models.py (Architectural Invariant #14 — response models are centralized, not declared in router files). - Cap limit on both list endpoints: limit: int = Query(200, le=500) — prevents unbounded DB reads (422 on >500). - requirements.md: §32.3 fleshed out (endpoints, recovery semantics, metadata-only/container-recreate-is-Phase-2, audit events, model location). - architecture.md: new "Soft-Delete Admin Recovery (#834 Phase 1c)" endpoint table with all 4 endpoints, auth, and behavior notes. Related to #834 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…-002 (#664) (#876) * test(security): backfill encryption tests for Telegram/WhatsApp/Slack-002 (#664) Adds 31 unit tests covering the AES-256-GCM encryption that already shipped on three channel bot-token columns: - `telegram_bindings.bot_token_encrypted` (db/telegram_channels.py) - `whatsapp_bindings.auth_token_encrypted` (db/whatsapp_channels.py) - `slack_workspaces.bot_token` (db/slack_channels.py, SLACK-002) Each suite covers: round-trip via the public ops API, raw DB envelope inspection (AES-256-GCM JSON with nonce + ciphertext), corrupt-envelope and wrong-key decryption failure, fresh-nonce on update, missing-key behavior. Slack-002 additionally pins the plaintext-fallback path at slack_channels.py:47-49 (legacy xoxb-* rows + operator WARNING log). WhatsApp pins the `account_sid` plaintext invariant so future refactors don't accidentally encrypt the public Twilio identifier. Mirrors the test pattern established in #453 (tests/unit/test_slack_token_encryption.py) for SLACK-001 / db/slack.py. Pure test backfill — no production code changes, no new dependencies. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: gitignore empty uv.lock and archive CSO diff audit for #664 - Adds `uv.lock` to .gitignore. The repo uses `pyproject.toml` only for pytest configuration (no `[project]` or `[tool.uv]` section). Running `uv` against this tree generates a 3-line empty lockfile with no `[[package]]` entries — checking it in adds churn with zero supply-chain signal. Flagged as L-1 in the #664 CSO diff audit. - Adds the CSO diff audit report alongside the historical reports in docs/security-reports/. Verdict: CLEAR (test-only backfill, no new surface, fixtures unambiguously fake). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…#915) The post-deploy PUT body documented in config/canary-fleet.yaml used `timeout_seconds`, but PUT /api/agents/{name}/timeout requires `execution_timeout_seconds` (returns "execution_timeout_seconds is required" otherwise). Caught while deploying the slow agent on dev.
…878) * fix(security): non-root USER directives for production images (#874) Closes the CSO MEDIUM defense-in-depth gap flagged persistent since 2026-04-05: backend, scheduler, MCP server, and the production frontend ran their CMD as root. An RCE in any of them inherited root, and on the backend that meant the Docker socket bind mount turned a single RCE into fleet-wide reconnaissance. Changes: - docker/backend/Dockerfile, docker/scheduler/Dockerfile: new `trinity` user at UID 1000 (matched UID required — both share /data/trinity.db). - src/mcp-server/Dockerfile: switch to the built-in `node` user (UID 1000). - docker/frontend/Dockerfile.prod: switch to `nginxinc/nginx-unprivileged` (UID 101, binds 8080). nginx.conf + healthcheck + compose port mapping updated to match. NET_BIND_SERVICE/CHOWN/SETGID/SETUID dropped from the frontend caps (no longer needed once nginx is unprivileged). - docker-compose{,.prod}.yml: backend joins `${DOCKER_GID:-999}` via group_add so UID 1000 retains /var/run/docker.sock access on Linux. Dead NET_BIND_SERVICE removed from backend (binds 8000, doesn't need it). PYTHONDONTWRITEBYTECODE=1 added to dev compose so uvicorn --reload stops failing on __pycache__ writes when host UID != 1000. - scripts/deploy/start.sh: pre-creates the host bind-mount data dir with UID 1000 (the Dockerfile's chown is masked by the bind mount); auto- detects DOCKER_GID on Linux (Debian/Ubuntu=999, RHEL/Fedora=~991, Arch=990) so non-Debian hosts don't silently fail with EACCES on the socket. - .env.example: DOCKER_GID ships blank so start.sh auto-detect kicks in. Compose still falls back to 999 if .env value is missing entirely. - .github/workflows/frontend-e2e.yml: `verify-non-root` step asserts UID 1000 in backend/scheduler/mcp-server and exercises the Docker socket via `docker.from_env().ping()` from inside the backend (the prior `/api/agents` probe was a false positive — `list_all_agents_fast` catches every Docker exception and returns []). `verify-prod-frontend-uid` builds the prod frontend image out-of-band and asserts UID 101. Admin password is generated per-run instead of the previous hardcoded fallback (CSO I-01). - docs/memory/architecture.md: new invariant #17 documenting the rule. - docs/migrations/NON_ROOT_CONTAINERS_2026-05.md: upgrade procedure for existing deployments — Docker only honours the Dockerfile chown on first volume creation, so trinity-data and agent-configs volumes from prior root-running containers need to be re-owned manually. - docs/security-reports/cso-diff-2026-05-17.md: audit report of the branch itself. Verification (CI, fresh prod, upgrade): see the migration doc. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ci(security): split non-root container guards into dedicated workflow Addresses @vybe review on #878. The verify-non-root and verify-prod-frontend-uid guards added in #874 lived inside frontend-e2e.yml, which is `ui`-label-gated — so backend infrastructure PRs (the exact PRs that can regress the guards) skipped them silently. Moves both steps to .github/workflows/container-security.yml with a path filter on docker/**, docker-compose*.yml, scripts/deploy/start.sh, and src/mcp-server/Dockerfile so the guards execute whenever the underlying surface changes — independent of the e2e workflow's UI gate. frontend-e2e.yml keeps the stack boot for Playwright smoke tests but no longer carries the regression guards. Architecture invariant #17 updated to point at the new workflow. * ci(security): set least-privilege GITHUB_TOKEN on container-security workflow Addresses CodeQL finding flagged on PR #878 (security/code-scanning/173): the new workflow defaulted to the repository-default GITHUB_TOKEN scope, which is broader than the workflow actually uses. Pin top-level `permissions: contents: read` — the minimum needed for actions/checkout. The workflow does no PR commenting, issue updating, or security-events writes, so anything beyond `contents: read` would be unused authority. * ci(security): flip setup_completed before auth probe in container-security The new container-security workflow calls /api/token after backend health is green, but on a fresh DB the first-time setup wizard blocks login (`setup_required`, 403) until `setup_completed=true`. Mirror the "Skip first-time setup wizard" step from frontend-e2e.yml — flip the flag directly via `docker exec trinity-backend python3 ...` so the CI sanity probe can mint a token and hit /api/agents. 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(tests): restore services.agent_client in sys.modules baseline (#762) test_voice_tools.py and test_fleet_status_resilience.py install incomplete stubs of services.agent_client into sys.modules at module-collection scope. The autouse restore in tests/conftest.py only restored keys whose baseline was non-None, and only ran for the non-unit tier (the unit tier sets norecursedirs = .. in tests/unit/pytest.ini and bypasses the parent conftest entirely). The polluted stubs missed CircuitState, so transitive importers (adapters → task_execution_service:32 → from services.agent_client import CircuitState) raised ImportError, taking down 19 tests in test_file_upload.py and 1 in test_session_persistence_flag.py. Two changes: 1. tests/conftest.py: add services.agent_client to _SYS_MODULES_INVARIANT_KEYS and pre-import it before the baseline is captured, so the baseline is a real module object that gets restored between tests (covers the non-unit tier). 2. tests/unit/conftest.py: mirror the baseline+autouse-restore mechanism for services and services.agent_client. The unit tier has its own rootdir; without this, the parent conftest's defenses never run for the unit suite. Unit tier: 20 failed → 4 failed (the 4 remaining are unrelated SQL schema and KeyError failures in test_extract_photo_largest_size and test_voice_auth, out of scope for this task). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tests): narrow except in services.agent_client preload to ImportError Follow-up to #762 — code-review feedback. except Exception: pass would mask non-import errors (e.g. side-effect runtime exceptions at module load) and silently degrade the autouse-restore defense. ImportError is the only expected failure mode for the preload. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tests): evict polluted services.task_execution_service stub in CB probe tests Seven tests in TestCircuitBreakerFastFail and TestCancelledErrorInExecuteTask failed with "object MagicMock can't be used in 'await' expression" — but only when test_validation.py ran first in the same pytest session. The TypeError fires at `await svc.execute_task(...)`, not on a single attribute mock. Root cause: test_validation.py does `sys.modules["services.task_execution_service"] = MagicMock()` at module-collection time. The conftest baseline-restore (#762) cannot undo it because the baseline value is `None` — the real task_execution_service module isn't loadable from conftest preload (it imports `database`, which mkdirs `/data` and fails outside Docker). The autouse restore explicitly preserves None-baseline entries to avoid clobbering deliberate stubs. When test_cb_probe later does `from services.task_execution_service import TaskExecutionService` it gets `MagicMock.TaskExecutionService`, instantiating returns a MagicMock, `svc.execute_task(...)` returns a MagicMock, and `await MagicMock` raises TypeError. Fix: in each affected class's autouse `_patch_env` fixture, pop `services.task_execution_service` from sys.modules before the test body's import — forcing a fresh load of the real module. Also replace the module-level `setdefault` of `utils.credential_sanitizer` with an unconditional install (test_validation.py installs a partial stub that lacks `sanitize_execution_log`, so setdefault was a no-op and the fresh task_execution_service import failed at `from utils.credential_sanitizer import sanitize_execution_log`). Verified: full file passes (10/10) standalone and after test_validation.py pollution. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(credentials): map agent-server connect errors to 503 on import/export Outcome (a) from the Task 5 plan: when an admin calls POST /api/agents/{name}/credentials/import on an agent whose container status is "running" but whose internal FastAPI server hasn't bound to port 8000 yet, `import_to_agent()` raises `httpx.ConnectError` ("All connection attempts failed"), which is not a `ValueError`. The existing handler had only `except ValueError → 400` and a bare `except Exception → 500`, so the transient race surfaced as a 500. `test_import_credentials_no_enc_file_fails` accepts 400 (file missing) or 503 (agent not ready) but never 500 — so the regression failed CI. Fix mirrors the pattern already used by `inject_credentials` (same file, line 250) and `routers/agent_files.py:82`: catch `httpx.RequestError` (parent of ConnectError / TimeoutException / ReadError) and map to 503 with a warning log. Applied symmetrically to `export_credentials` since it has the same shape and would 500 on the same transient condition. `CredentialsFileNotFoundError(ValueError)` is unaffected — when the agent server *is* reachable but no `.credentials.enc` exists, `import_to_agent()` still raises that ValueError subclass and the existing 400 mapping still fires. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(env): auto-load TRINITY_TEST_PASSWORD + REDIS_BACKEND_PASSWORD from .env Closes the env-friction set surfaced by the May 2026 test-recovery audit: - tests/setup-env.sh sourced by run-*.sh exports the four vars pytest needs from project .env. - tests/conftest.py picks them up the same way for direct pytest invocation (alias ADMIN_PASSWORD -> TRINITY_TEST_PASSWORD). - tests/requirements-test.txt installs src/cli editable so test_cli_admin_login.py and test_cli_profiles.py can collect. - tests/README.md documents the env matrix, tiers, the rate-limit + setup-completed recovery commands, and the Conductor-worktree backend-mount caveat. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tests): override placeholder REDIS_BACKEND_PASSWORD with .env value The setdefault chain in tests/conftest.py was order-dependent: line 30 unconditionally setdefault'd "test" as a placeholder for backend-config import safety, which made the subsequent setdefault from .env a no-op. tests/security/ ACL tests then ran with the wrong password and failed with NOAUTH unless the caller manually exported REDIS_BACKEND_PASSWORD before pytest. Switch to explicit override: when the .env value is present AND the current env-var is either unset or the "test" placeholder, replace it. An explicit caller export (any value other than "test") still wins. Follow-up to dbdb808. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(lint): skip .venv/__pycache__ in sys_modules linter; regen baseline Two changes: 1. lint_sys_modules.py:iter_test_files — skip directories that contain third-party / generated code (.venv, venv, __pycache__, .pytest_cache, node_modules). The previous rglob walked into tests/.venv/lib/python3.11/site-packages and reported 30+ pseudo- violations in dependency code that vary per machine / dep version. The baseline already excluded these (zero .venv entries) so the committed baseline was machine-relative without anyone noticing until a dep upgrade exposed the drift. 2. tests/lint_sys_modules_baseline.txt — regenerate. Two real changes: - test_cb_probe_execution_close.py: 5 → 7 (+2 sys.modules.pop calls added in commit f586532 to evict cross-file stub pollution). - tests/unit/test_cleanup_unreachable_orphan.py removed (cleaned up during the dev rebase). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(tests): post-recovery report (May 2026 test-suite audit) Summary of the 7 fixes landed in this branch (CircuitState sys.modules, MagicMock-await eviction, credentials 503 mapping, env friction docs + helpers, lint baseline regen + linter .venv exclusion) plus an inventory of out-of-scope failures and Conductor-worktree caveats that need follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(subprocess-pgroup): regression test for #586 setsid pipe-holder Adds test_setsid_escapee_drained_via_orphan_killer_preserves_result_line to pin the full production path: parent emits result, forks a setsid()'d grandchild that holds stdout open, parent exits. Asserts that drain_reader_threads invokes _kill_orphan_pipe_writers and the buffered "RESULT_LINE" survives — i.e. the drain doesn't fall through to the force-close fallback that discards the kernel pipe buffer. Sibling of the #531 buffered-data test, distinct because the grandchild calls os.setsid() — the case that escapes terminate_process_group(pgid) and is the real-world signature in production (git push → ssh). Also adds a KNOWN_ISSUES.md entry describing the bug class, the platform fix (#620), and operator-side defense-in-depth for stop hooks that spawn network processes. Refs #586 Co-Authored-By: Claude <noreply@anthropic.com> * docs(feature-flows): backfill notes for #602/#830, 35d4e78, #759/#779 - agent-lifecycle.md / container-capabilities.md: document Phase 3c capability drop (#602 / PR #830, 2026-05-13) — SYS_PTRACE, MKNOD, NET_RAW, FSETID removed from FULL_CAPABILITIES (now 9 caps, was 13); constants extracted to capabilities.py so test_capability_set.py can import them stdlib-only. - credential-injection.md: document 503 mapping for import/export endpoints (commit 35d4e78, 2026-05-17) — httpx.RequestError now surfaces 503 instead of 500, mirroring inject and agent-files. - session-tab.md: update lock semantics for #779 — cold turns now serialised on session_lock:cold:{session_id} (previously short- circuited, letting two concurrent first POSTs race on update_cached_claude_session_id and orphan one JSONL inside the agent). - feature-flows.md: index entries for the above. Co-Authored-By: Claude <noreply@anthropic.com> * docs(security): CSO daily audit 2026-05-17 + diff report 2026-05-13 - cso-2026-05-17.{md,json}: daily full-phase audit (Phases 0-14). Verdict CLEAR at 8/10 confidence gate; one persistent MEDIUM (Dockerfile USER hardening — tracked since 2026-04-05). No new CRITICAL or HIGH findings. - cso-diff-2026-05-13.md: diff-mode report covering the working-tree changes for the #586 regression test and KNOWN_ISSUES.md entry. No vulns or secrets — docs + test-only, no production code path. Co-Authored-By: Claude <noreply@anthropic.com> * chore(.claude): bump submodule (DEVELOPMENT_WORKFLOW.md) Picks up the methodology toolkit head that adds DEVELOPMENT_WORKFLOW.md. Co-Authored-By: Claude <noreply@anthropic.com> * fix(tests): resolve -e src/cli path from repo root (CI green) pip resolves relative paths in requirements files against the current working directory, not the requirements file. `-e ../src/cli` (added in dbdb808) only worked when pip was invoked from `tests/`; CI runs from the repo root and got `../src/cli` -> outside workspace -> 6 pytest jobs + schema-parity + regression-diff all red on PR #875. Switch to `-e ./src/cli` and update tests/README.md to instruct invoking from repo root. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tests): restore sys.modules["config"] after test_config_fail_fast reload test_config_accepts_url_with_credentials calls _reload_config() which pops sys.modules["config"] and re-imports. The reloaded module reads SECRET_KEY from os.environ at reload time, picking up "test-secret-key-for-unit-tests" from test_voice_auth.py's os.environ.setdefault — but the *original* config (loaded at conftest pre-import time, before that env was set) had a random SECRET_KEY from secrets.token_hex(32). The test then leaves the new module in sys.modules permanently. Downstream, routers/voice.py's runtime `from config import SECRET_KEY, ALGORITHM` (called from voice_websocket) reads the new SECRET_KEY, while test_voice_auth.py's JWTs were signed with the original key captured at its module-collection time. JWT decode raises JWTError, voice_websocket closes 4001 instead of the expected 4003/accept, and the 3 ownership-gate tests fail — but only under pytest-randomly seeds that schedule test_config_fail_fast before test_voice_auth (notably seed 12345 after PR #875 added test_subprocess_pgroup.py and shifted the random ordering). Snapshot/restore sys.modules["config"] via an autouse fixture so each test's reload is scoped to itself. The 3 voice_auth tests (test_owner_passes_auth_gate, test_other_user_rejected_4003, test_admin_bypasses_ownership) now pass on seed 12345. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tests): use project-standard sys.modules restore helper in config_fail_fast Renames _restore_config_module to the lint-recognized _STUBBED_MODULE_NAMES + _restore_sys_modules pair so the helper-exception in tests/lint_sys_modules.py fires (precedent: tests/unit/test_telegram_webhook_backfill.py). The previous attempt (0966243) used a bespoke fixture name + bare sys.modules.pop, which is exactly what the #762 hygiene linter bans outside conftest.py — the lint job went red on push. Same behavior, correct pattern, exempted by the linter. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(tests): baseline 6 pre-existing sys.modules mutations in test_slot_per_slot_ttl.py #871 landed on dev (commit 98574f3) with test_slot_per_slot_ttl.py containing 6 bare sys.modules.{pop,setdefault,assign} calls but no matching baseline entry. Dev's own post-merge lint job is now red on that commit (Issue #802 caught it as intended). Any PR that merges with current dev inherits the failure. This PR is unrelated to the slot file but blocked by the inherited red. Bump the baseline to match what dev actually ships, unblocking CI here. The proper fix — refactor test_slot_per_slot_ttl.py to use the _STUBBED_MODULE_NAMES + _restore_sys_modules helper pattern, or scope its sys.modules mutations via monkeypatch — should land as a follow-up on #871's author or whoever owns the slot file (file is otherwise untouched here). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Revert "chore(tests): baseline 6 pre-existing sys.modules mutations in test_slot_per_slot_ttl.py" This reverts commit 459b535. * chore(.claude): revert submodule regression to match dev The branch's submodule bump (3873d2c) was a parent of dev's current pointer (9650477), so merging would silently revert the "fix(announce): prevent duplicate Slack sends by switching to Python urllib" change in .claude. Resetting the submodule pointer to dev's current state effectively drops the bump from this PR. The DEVELOPMENT_WORKFLOW.md doc added by 3873d2c is still present (9650477 includes it as an ancestor). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Eugene Vyborov <eugene@beingluminous.com>
- bump .claude submodule (skill updates: create-issue public-repo awareness, announce multi-account Twitter, test-runner tweaks) - CLAUDE.md: add agent-defined pipelines rule (§8) - requirements.md: minor edits - add docs/demos/platform-demo-scenario.md (platform demo script, paths converted to repo-relative) - add docs/research/a2a-protocol.md (A2A protocol research notes) - add docs/CLAUDE-md-review-2026-05-11.md (review notes) - add docs/metrics/* (engineering metrics reports) - add docs/user-docs/dev-announcements/* (announcement copies) - add docs/screenshots/oracle-system.png Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…924) * fix(#921): two-cycle confirmation for watchdog orphan recovery The agent's claude_code.py unregisters its process registry in a `finally` block BEFORE task_execution_service writes `success` to the DB. A single watchdog snapshot taken in that window cannot distinguish a completing execution from a true orphan, so cleanup_service was marking healthy executions FAILED and releasing their slots — triggering the slot saturation → CB-open → CB-dormant cascade described in #921. Recovery now requires two consecutive sightings of (DB-running + agent-missing). Redis sentinel `watchdog:suspected_orphan:{eid}` gates the recovery: the first sighting writes the sentinel and defers; only the second confirms and recovers. The natural-completion race resolves between cycles (DB row becomes terminal) so the second cycle never sees the row in the running query. True-orphan recovery latency goes from 0 to ~5 min — acceptable for the safety-net role. - Added orphans_suspected report field (informational, not in `total`) - Redis client is lazy + async + fail-open: on Redis outage the watchdog reverts to legacy single-cycle behaviour rather than blocking recovery fleet-wide. - Out of scope (separate work in #921): CB dormant-state stuck-forever behaviour, admin reset endpoint. This change prevents the false- positive that trips the CB in the first place. Unit tests: 4 new tests in TestTwoCycleOrphanConfirmation cover first- cycle deferral, second-cycle recovery, the natural-completion race, and agent-flap sentinel clearing. 7 existing tests updated for the new 4-tuple return shape and sentinel mocking. Integration tests: 3 new tests in test_watchdog.py drive the live POST /api/monitoring/cleanup-trigger endpoint against the running backend, with DB/Redis fixture helpers and per-test teardown. * fix(#921): dormant CB self-heals via long-cooldown probe The dormant state was "stop probing — wait for external intervention", which produced the observed 14.5h false-fail outage in #921 when nothing external happened to reset the breaker. Restore baseline recovery: in dormant, allow exactly one probe per CIRCUIT_DORMANT_COOLDOWN_SECONDS (default 1h) under the same probe-lock as open state. Bounds the worst- case dormant outage to ~1h instead of "until a human notices". Implementation: - _ALLOW_REQUEST_LUA: drop the `state == 'dormant' return 'deny'` short- circuit so dormant falls through to the same next_probe_at + probe-lock dance as open. - _RECORD_FAILURE_LUA: when transitioning to or while in dormant, set next_probe_at = now + dormant_cooldown (passed as new ARGV[6]) instead of the open exponential-backoff curve. - Probe success resets to closed via existing _RECORD_SUCCESS_LUA; probe failure increments probe_count and re-arms next_probe_at to another full dormant cooldown. - Updated log message — "manual recovery required" is no longer accurate. Out of scope: force_circuit_dormant (operator-initiated) keeps using CIRCUIT_MAX_COOLDOWN_SECONDS — that's the autonomy-off pause flow from #631, not the failure-cascade dormancy this fix targets. Tests: test_dormant_denies_all_requests reframed as test_dormant_denies_within_cooldown (semantics clarified — still denies during the cooldown window). New test_dormant_probes_after_cooldown proves the post-cooldown probe is admitted and the probe-lock is held. 32/32 CB integration tests pass. * feat(#921): operator-queue alert on CB dormant transition Surface the closed/open → dormant transition as a high-priority operator_queue entry so the Operating Room UI shows "agent silently failing scheduled tasks" without operators having to grep logs. In the incident behind #921 nobody noticed the dormant state for 14.5h because the only signal was a single WARN log buried in backend output. Implementation: - New _emit_dormant_alert(agent_name) module-level helper in agent_client.py. Lazy imports `database.db` to avoid pulling SQLite into the agent_client import chain during early startup. - Called from CircuitState.record_failure on the prior!=dormant → new==dormant transition. The atomic Lua in _RECORD_FAILURE_LUA guarantees exactly one worker observes that transition across the uvicorn pool, so the alert fires once per distinct dormant entry — no de-dupe layer needed. - Failure-tolerant: if the DB write blows up, we log via `logger.exception` and the CB transition itself is unaffected. Tests: test_dormant_transition_emits_operator_queue_alert in integration/test_circuit_breaker.py stubs `database` in sys.modules, drives the breaker to dormant via real Redis transitions, and asserts - exactly one create_operator_queue_item call on transition - item shape: type=circuit_breaker_dormant, priority=high, context has agent_name + transition + dormant_cooldown_seconds - subsequent failures while dormant do NOT fire a second alert (verifies the once-per-entry guarantee) Live verification: drove trinity-system CB to dormant against the running stack; operator_queue row appeared with all expected fields. * feat(#921): POST /api/agents/{name}/circuit-breaker/reset (admin) Admin escape hatch for the dormant-CB cascade described in #921 — equivalent to the manual `redis-cli DEL agent:circuit:{name}` workaround. The dormant→half-open auto-probe (1h cooldown) already self-heals the breaker, but this endpoint is the first-response tool when an operator already knows the agent is healthy and doesn't want to wait a full cooldown. Implementation: - New route in routers/agents.py. Mounts at the path requested in the ticket so the URL is predictable for runbooks. - Auth: require_role('admin'). Operator-only action; not exposed to agent owners (resetting another tenant's CB isn't theirs to do). - Calls the existing services.agent_client.reset_circuit helper that was already used by /api/monitoring/agents/{name}/check; keeps a single source of truth for the DEL semantics. - Response includes `prior_state` so incident postmortems can read off what state the operator reset out of (closed | open | dormant). Tests: tests/test_circuit_breaker_reset.py — 4 integration tests driving the live stack: - happy path: force dormant → reset → 200 with prior_state=dormant, Redis key deleted - idempotent on closed CB (no Redis key present): 200, prior=closed - unauthenticated → 401/403 - unknown agent → 404 (verifies AuthorizedAgentByName wiring) Live verification: parked trinity-system's CB dormant, hit the new endpoint, received correct prior_state=dormant response, confirmed the Redis hash was deleted. * fix(#921): /review follow-ups — UI-renderable dormant alert + probe-cadence test Addresses two informational findings from /review on PR #924: [I1] Operator-queue alert now uses the generic `type: "alert"` so the existing Operating Room UI (QueueCard.vue / QueueItemDetail.vue branch on 'approval|question|alert') renders an Acknowledge control. The narrower CB-specific marker moves to `context.alert_type = "circuit_breaker_dormant"` for callers that need to filter. Without this, operators saw the alert but couldn't ack it via the UI. Same pattern the existing `sync_failing` work should pick up when its UI surface is touched. [I2] New test `test_dormant_probe_failure_rearms_full_dormant_cooldown` in tests/integration/test_circuit_breaker.py locks in the cadence the #921 dormant-self-heal promises: after a dormant probe FAILS, next_probe_at is rearmed to the full DORMANT_COOLDOWN (~1h), NOT the open-state exponential backoff curve. Uses a wide gap between the two cooldown families (0.5s vs 0.001s) so the assertion can distinguish them. Without this guard a future refactor could regress the cadence and operators would lose the predictable hourly probe. 84/84 tests pass across the four #921-touching files.
…927) Document replica groups as the target-architecture answer to per-agent throughput limits — one logical agent, N container instances, Redis Streams consumer group, single-writer election for shared state. Distinct from agent cloning, which creates divergent siblings and pushes routing to every caller. Adds a corresponding entry under Key Open Questions. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ter, add drift/soak checks Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…#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>
…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>
…dmin-email feat(setup): first-run operator intake + admin email login (trinity-enterprise#38, #82)
…ev-sync docs(user-docs): video library + per-page links, v0.6.1 What's New, sync dev features
…-ref docs(voip): genericize moved-issue reference in feature-flow
…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>
docs: incubating direction + voip flow reword + CSO 2026-06-21 report
…e#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>
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>
…rrupted 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>
…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>
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>
…izard feat: streamline first-time setup wizard (Abilityai/trinity-enterprise#49)
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>
…d-media feat(whatsapp): outbound media attachments via Twilio MediaUrl (#1315)
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>
…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>
…igration 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>
…y-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>
|
|
||
| def _delete(execution_id: str) -> None: | ||
| try: | ||
| _pending_path(execution_id).unlink(missing_ok=True) |
| _PENDING_DIR.mkdir(parents=True, exist_ok=True) | ||
| tmp = _pending_path(execution_id).with_suffix(".json.tmp") | ||
| tmp.write_text(json.dumps(record)) | ||
| tmp.replace(_pending_path(execution_id)) |
| _PENDING_DIR.mkdir(parents=True, exist_ok=True) | ||
| tmp = _pending_path(execution_id).with_suffix(".json.tmp") | ||
| tmp.write_text(json.dumps(record)) | ||
| tmp.replace(_pending_path(execution_id)) |
| try: | ||
| _PENDING_DIR.mkdir(parents=True, exist_ok=True) | ||
| tmp = _pending_path(execution_id).with_suffix(".json.tmp") | ||
| tmp.write_text(json.dumps(record)) |
Comment on lines
+282
to
+289
|
|
||
| logger.info( | ||
| f"Assigned subscription '{subscription_name}' to agent '{agent_name}' " | ||
| f"by {current_user.username}" | ||
| ) | ||
|
|
||
| restart_result = None | ||
| injection_result = None |
…gres-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>
AndriiPasternak31
approved these changes
Jun 23, 2026
This was referenced Jun 23, 2026
This was referenced Jun 23, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Release v0.7.0 — cumulative changes since
v0.6.1.🐘 PostgreSQL goes to production (the theme)
Trinity now runs on PostgreSQL as the recommended production backend — configurable via a single
DATABASE_URL(#300), with Alembic-managed migrations (#1183) and a dated SQLite end-of-support: 2026-09-01 (#1278). SQLite stays the zero-config default for local dev/eval.Migrate an existing SQLite instance with the Trinity Ops Agent's
/migrate-to-postgresskill (abilityai/trinity-ops-public) — gated validate-then-cutover, SQLite file never written, one-line rollback. Stand-up:docs/POSTGRESQL_SETUP.md.Also in this release
data_paths+ snapshot/restore/exportFull notes:
docs/releases/0.7.0.md(in this PR).Closes #82
Closes #300
Closes #668
Closes #679
Closes #722
Closes #767
Closes #799
Closes #858
Closes #941
Closes #953
Closes #954
Closes #957
Closes #958
Closes #960
Closes #1022
Closes #1025
Closes #1027
Closes #1082
Closes #1083
Closes #1088
Closes #1089
Closes #1095
Closes #1104
Closes #1115
Closes #1116
Closes #1159
Closes #1160
Closes #1165
Closes #1169
Closes #1183
Closes #1187
Closes #1197
Closes #1199
Closes #1200
Closes #1201
Closes #1230
Closes #1231
Closes #1237
Closes #1260
Closes #1264
Closes #1265
Closes #1267
Closes #1278
Closes #1315
v0.7.0(VERSION file already bumped on dev)publish-cli.ymlauto-publishes trinity-cli 0.2.7 via the main-push path (src/cli touched)trinity-enterprise#5closed manually post-merge (cross-repo keyword limit)🤖 Generated with Claude Code