security: implement safe tar extraction with symlink/hardlink validation - #8
Merged
Merged
Conversation
Add comprehensive archive validation for local agent deployment to prevent path traversal attacks via symlinks and hardlinks. Changes: - Add _is_path_within() helper for path containment checks using resolve() - Add _validate_tar_member() to validate each archive member: - Rejects absolute paths and path traversal (../) - Rejects symlinks/hardlinks pointing outside extraction directory - Rejects device files (chr/blk) and FIFOs - Allows internal symlinks/hardlinks that resolve within temp_dir - Add _safe_extract_tar() wrapper that validates all members before extraction - Replace simple startswith/'..' check with full validation - Add unit tests for validation logic (tests/test_archive_security.py) Addresses H-02 finding from security scan.
oleksandr-korin
added a commit
that referenced
this pull request
Jan 19, 2026
Interactive Test Results: - I1: Approval Routes ✅ - I2: Multi-Stage Approval ✅ (revealed output bug) - I3: Complex Workflow (Gateway + Approval) ✅ - I4: Parallel Work + Approval ✅ NEW BUGS FOUND: - Issue #8: Approval decision NOT stored in step output - Conditions like 'steps.X.output.decision == approved' fail - Impact: Cannot route based on approval value UI ISSUES CONFIRMED: - Page doesn't auto-refresh when approval step becomes active - Must manually refresh to see Approve/Reject buttons - Confirmed across I1, I2, I3, I4 - Skipped steps don't show WHY they were skipped 4 interactive scenarios created for future testing: - processes/interactive/i1-approval-routes.yaml - processes/interactive/i2-multi-stage-approval.yaml - processes/interactive/i3-complex-workflow.yaml - processes/interactive/i4-parallel-work-approval.yaml
oleksandr-korin
added a commit
that referenced
this pull request
Jan 19, 2026
Interactive Test Results: - I1: Approval Routes ✅ - I2: Multi-Stage Approval ✅ (revealed output bug) - I3: Complex Workflow (Gateway + Approval) ✅ - I4: Parallel Work + Approval ✅ NEW BUGS FOUND: - Issue #8: Approval decision NOT stored in step output - Conditions like 'steps.X.output.decision == approved' fail - Impact: Cannot route based on approval value UI ISSUES CONFIRMED: - Page doesn't auto-refresh when approval step becomes active - Must manually refresh to see Approve/Reject buttons - Confirmed across I1, I2, I3, I4 - Skipped steps don't show WHY they were skipped 4 interactive scenarios created for future testing: - processes/interactive/i1-approval-routes.yaml - processes/interactive/i2-multi-stage-approval.yaml - processes/interactive/i3-complex-workflow.yaml - processes/interactive/i4-parallel-work-approval.yaml
vybe
added a commit
that referenced
this pull request
Feb 10, 2026
- Update @modelcontextprotocol/sdk to ^1.26.0 (fixes CVE cross-client data leak) - Update hono to >=4.11.7 (fixes 4 moderate vulnerabilities) - Update fastmcp to ^3.32.0 - Add npm overrides to force patched versions in transitive deps Fixes Dependabot alerts #8-17 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This was referenced Apr 12, 2026
vybe
added a commit
that referenced
this pull request
Apr 18, 2026
…idate-architecture Backend: unregister 7 Process Engine routers (processes, executions, approvals, triggers, alerts, process_templates, audit) plus the process-docs router; drop startup hooks for execution recovery and the process-engine WebSocket publisher. Services under services/process_engine/ remain in place as dormant code. Frontend: remove 11 process-related routes (/processes, /processes/new, /processes/docs, /processes/wizard, /processes/:id, /executions, /approvals, /executions/:id, /process-dashboard) and the dead isProcessSection computed in NavBar. Keep /alerts and /events legacy redirects to Operating Room. Docs: correct stale count claims in architecture.md (main.py line count, router count 45 -> 53, service count 23 -> 37, MCP tool modules 15 -> 16) and expand database.py scope description to reflect 27 domain op classes. Skill: expand validate-architecture to detect drift between arch.md and code: count alignment (D1), scope coherence (D2), enforced MCP parity under #13 (tool module OR '# mcp: none' opt-out), and inline authorization sprawl detection under #8. Output now includes suggested arch.md edits with line numbers, not just pass/fail. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This was referenced Apr 25, 2026
vybe
pushed a commit
that referenced
this pull request
Apr 27, 2026
Add Depends(get_current_user) to the three handlers in src/backend/routers/docs.py so the file no longer violates Architectural Invariant #8. Note: this router is not currently registered in main.py, so the endpoints are not reachable on the running API. The fix is applied to the file as written so the invariant validator stops flagging it and so the file is correct if it is ever remounted. Closes #452
4 tasks
vybe
added a commit
that referenced
this pull request
Apr 30, 2026
* feat(webhooks): agent schedule webhook triggers (WEBHOOK-001, #291)
Add public webhook URLs so external systems (CI/CD, CRMs, monitoring) can
trigger agent schedule executions via a simple HTTP POST with no Trinity
account required — authenticated by a 256-bit opaque token embedded in the URL.
Changes:
- New public router POST /api/webhooks/{token}: rate-limited (10/60s per
token), audit-logged, 202 Accepted, delegates to existing scheduler trigger
- JWT-auth CRUD: POST/GET/DELETE /api/agents/{name}/schedules/{id}/webhook
- DB migration: webhook_token (TEXT UNIQUE), webhook_enabled (INTEGER DEFAULT 0)
on agent_schedules; partial unique index for O(1) token lookup
- Scheduler updated to accept triggered_by param in JSON body so executions
record triggered_by="webhook" correctly
- Webhook context field framed as data to reduce prompt injection surface
- 12 integration tests in tests/test_webhook_triggers.py
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(security): patch 4 Dependabot alerts — happy-dom + vite (#486)
Bumps two dev-only dependencies to patched versions. Production is
unaffected (happy-dom is test-only; vite only runs in local dev).
- src/frontend: vite ^6.0.6 → ^6.4.2 (closes Dependabot #55, CVE-2026-39363)
- tests/git-sync: happy-dom ^15.11.7 → ^20.9.0 (closes #83/#84/#85:
VM context escape RCE, ESM code exec, fetch cookie leakage)
Verified: frontend `vite build` clean, all 10 git-sync vitest tests pass
under happy-dom 20.9.0. No new critical/high alerts introduced.
Closes #485
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(webhooks): add WEBHOOK-001 to requirements and architecture (fixes #484 review)
Add missing documentation for the webhook trigger feature:
- requirements.md: WEBHOOK-001 entry with description, key features, DB changes,
API endpoints, security model, and feature flow link
- architecture.md: webhooks.py listed in Routers table; Schedules table expanded
from 9 to 12 endpoints with the 3 webhook management endpoints; new Webhook
Triggers section documenting the public POST /api/webhooks/{token} endpoint;
webhook_token and webhook_enabled columns added to agent_schedules schema block
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(#488): add status-in-dev label + PR-merge automation (#489)
Close the gap between "PR merged to dev" and "released to main".
- New GH Action `issue-status-on-merge.yml`: on PR merge to dev,
parse Fixes/Closes/Resolves #N from PR body+title, add
`status-in-dev`, remove `status-in-progress`.
- `/release` skill: read `gh issue list --label status-in-dev` as
the authoritative shipping list for release notes; include
`Closes #N` in the release PR body so issues auto-close on merge
to main.
- `DEVELOPMENT_WORKFLOW.md`: SDLC is now Todo → In Progress →
In Dev → Done, each stage mapped 1:1 to commit-graph location.
Fixes #488
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(channels): file upload Phase 2 — workspace delivery hardening (#487) (#494)
Phase 2 of #354 polishes the shared channel-agnostic file delivery path
in `message_router._handle_file_uploads`. Phase 1 (#355) added Telegram
extraction/download/validation; the actual workspace write path was
introduced for Slack inbound (#222). This change hardens the shared path
for both channels:
- New `_sanitize_filename` helper: NFKC unicode normalize → basename →
safe-chars regex → empty/dotfile fallback to `file_{id}` → 200-char
truncation preserving extension → collision dedup with `-1`, `-2`, …
- Spec injection format: `[File uploaded by {uploader}]: {name} ({size})
saved to {path}`. Uploader is the verified email when present
(Issue #311), else `adapter.get_source_identifier(message)`.
- All-writes-failed handling: when every workspace write attempt fails,
the router replies on the channel with an explicit error and skips
agent execution (#487 AC6). Validation rejections (size/MIME/download
errors) still surface in the description block as before.
- Audit log entries gain an `uploader` field.
Per-session upload directory (`/home/developer/uploads/{session_id}/`)
preserved — keeps user uploads isolated and ephemeral, matches the
existing #222 model.
Tests: +17 unit tests across `TestFilenameSanitization` (12),
`TestFileDeliveryFormat` (2), `TestFileDeliveryFailures` (3). 28/28
passing in `tests/unit/test_file_upload.py`.
Docs: `telegram-integration.md` Phase 2 section + revision row;
`slack-file-sharing.md` flow / router / errors / security sections
updated for the shared change; `feature-flows.md` index row.
Closes #487
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(webhooks): import Request in schedules router (#495)
The WEBHOOK-001 commits (c630931 / 8fdf736) added `request: Request`
parameters to `generate_webhook` and `get_webhook_status` without
importing `Request` from fastapi. Backend module import fails with
NameError on startup, blocking all dev deploys.
Integration tests in tests/test_webhook_triggers.py exercise these
endpoints but never caught the bug because the backend never starts —
test setup fails before any test runs.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(backlog): repair drain spawn — lazy-import target after #95 (#496) (#500)
services/backlog_service.py:240 lazy-imported _execute_task_background
from routers.chat, but #95 deleted that function. Every backlog drain
attempt failed with ImportError; the exception was swallowed at
backlog_service.py:218-228, so BACKLOG-001 (#260) was silently dead.
Live observation: 23 drain failures / 24h on a fan-out workload, only
surface signal was the per-execution `error` column.
Why it shipped silently: the unit happy-path test patched
sys.modules["routers.chat"] with a SimpleNamespace stub of whatever
attribute name it expected, masking the production breakage.
Changes:
- Lazy-import _run_async_task_with_persistence (the post-#95
replacement) and adjust the call shape (drop release_slot, drop
orphaned task_activity_id; the unified executor handles both).
- Capture self-task fields (is_self_task, self_task_activity_id,
inject_result) at enqueue time and rehydrate on drain so
SELF-EXEC-001 (#264) survives backlog overflow.
- Emit a stable log token `backlog_drain_spawn_failed` so log-based
detection (Vector / dashboards) can catch import drift or similar
spawn-time regressions at fleet scale rather than per-row.
- AST-based regression guard in tests/unit/test_backlog.py:
TestLazyImportTarget parses routers/chat.py and asserts the import
target exists; paired test asserts the lazy-import string matches
the validated allow-list. Catches both directions of drift without
booting the backend.
- Update happy-path test to use the new symbol and kwarg surface;
add self-task enqueue+drain round-trip tests.
Closes #496
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(announce): add Twitter/X support via API v2 + OAuth 1.0a
Bumps announce skill to v1.6. Adds a Python helper (scripts/post_twitter.py)
that reads tweet text from stdin and posts via Twitter API v2 using OAuth 1.0a
User Context — same exit-0/1 + structured-JSON contract as the existing
Discord/Slack/Telegram send paths so the sequential-only and no-blind-retry
rules apply uniformly. Credentials live in .env (gitignored) under
ANNOUNCE_TWITTER_* keys.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(chat): sync /task long-polls on backlog at capacity (#498) (#515)
Sync parallel `/task` calls (parallel=true, async=false) at capacity used
to fail terminally with HTTP 429 — they never touched the BACKLOG-001
backlog because the spill block was nested under `if request.async_mode:`.
Observed in production: ~40% terminal-failure rate from one MCP fan-out
caller (214 capacity rejections / 24h, 0 enqueues from 541 dispatches).
Sync calls now spill to the same backlog the async path uses and long-poll
on the open HTTP connection until the queued execution reaches a terminal
status, then return the result inline. True 429 only when the backlog is
also full. Total connection hold capped at 2 × effective_timeout.
Implementation:
- New `services/sync_waiter.py` owns the in-process registry and the
`signal_sync_waiter` / `wait_for_sync_terminal` primitives. Wait combines
an asyncio.Future (set by the drain finally block) with a 5s DB-poll
fallback that covers terminal flips routed outside the drain
(corrupt-metadata, expire_stale, cleanup recovery).
- `routers/chat.py` sync branch now mirrors the async branch:
pre-acquires the slot, on at-capacity calls `backlog.enqueue()` then
`wait_for_sync_terminal()`, returns the inline result on wake.
- `_run_async_task_with_persistence` wraps its body in try/finally and
signals any registered sync waiter with the rich TaskExecutionResult
plus chat_session_id. No-op when no waiter is registered (the common
async fire-and-forget path).
Tests (`tests/unit/test_chat_sync_backlog.py`, 13 new):
- Signal / wait / poll-fallback / timeout / cleanup / concurrent waiters
- Regression test pins TERMINAL_TASK_STATUSES to the enum so a new
TaskExecutionStatus value forces a deliberate update (caught a missing
SKIPPED entry pre-merge)
Trade-off (Policy B): worst-case connection hold doubles to
2 × effective_timeout when the request is queued. Honest envelope —
the caller chose to wait. Documented in the architecture diagram of
`persistent-task-backlog.md`.
Companion issue #505 covers the orchestration-education gap (MCP tool
description + platform prompt) so agents pick the right tool for the
job rather than relying on the platform absorbing every misuse.
Closes #498
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(groom): document SDLC stages and add status-label/board reconciliation
Adds SDLC context (Todo → In Progress → In Dev → Done) so grooming respects
in-flight work, and a Step 1b that reconciles status-* labels with board
columns (labels are authoritative).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(agent): classify signal-killed claude exits as 504, not fake auth failure (#517)
External signal terminations of the claude subprocess (timeout SIGKILL,
OOM-kill, parent SIGTERM, operator cancel) used to fall through to the
auth-fallback heuristics and surface as a misleading "Subscription token
may be expired" 503. Same shape as #361 (max-turns), different exit path.
Adds _classify_signal_exit() consulted before the auth heuristics: matches
Python-native signal exits (return_code < 0) and shell-encoded forms
(130/137/143 for SIGINT/SIGKILL/SIGTERM) and raises HTTP 504 with a clear
"killed by SIGKILL/SIGTERM/SIGINT — likely timeout, OOM, or operator
cancel" message. Tightens the zero-token heuristic with return_code > 0
so signal exits cannot reach it.
The bug became routinely reproducible after #61 (PR #326) added
backend-driven terminate_execution_on_agent() — every timeout now
produces a signal-killed claude subprocess on the agent side, which the
old heuristic block misclassified. Also de-risks PR #508 (auth-class
auto-switch): without this fix, every timeout would trigger an
unnecessary subscription rotation.
Backend's task_execution_service.py only flags AUTH on 503; 504 falls
through to the generic FAILED path. No backend changes required.
Closes #516
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(sprint): align skill with DEVELOPMENT_WORKFLOW.md (#519)
Four divergences between the /sprint playbook and the SDLC documented in
docs/DEVELOPMENT_WORKFLOW.md:
- Step 3 used `gh issue edit --add-label status-in-progress` directly,
bypassing .github/workflows/claim.yml and skipping self-assignment.
Now posts `/claim` as an issue comment, which is the workflow's single
source of truth for the In Progress transition.
- Step 8 invoked pytest directly via `cd tests && source .venv/bin/activate
&& python -m pytest …`. Now defers to `/test-runner [feature]`, with a
documented fallback for brand-new files outside the runner's catalog.
- Step 10 commit + PR body used `closes #N`. Workflow §1 specifies
`Fixes #N`; both auto-close on GitHub but the doc is the contract.
- Step 11 final report didn't mention the post-merge automation. Now
warns that issue-status-on-merge.yml owns the
status-in-progress → status-in-dev transition, so operators don't
manually edit labels post-merge.
Non-breaking: argument signature, automation level (gated), state
dependencies, and pipeline overview unchanged. Net +19/-11.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(agent): classify clean-exit empty-result as 502, not silent success (#520) (#521)
* fix(agent): classify clean-exit empty-result as 502, not silent success (#520)
Sibling of #516/#517 on the return_code == 0 path. When the claude
subprocess exits 0 but the final {"type":"result"} JSON line is dropped
before the reader thread captures it (typical cause: a child subprocess
inherited stdout, kept the pipe open past claude exit, the reader thread
leaked, the pgroup unwind closed the pipe), metadata.cost_usd and
metadata.duration_ms stay None. The success path used to return HTTP 200
anyway — agent-server logged "completed successfully" while backend
silently reaped the execution as an orphan minutes later, masking the
real failure with a misleading "completed on agent but recovered by
watchdog" message.
Adds _classify_empty_result(metadata, raw_message_count) consulted after
the return_code != 0 block (#516 + auth heuristics) and before response
building. When both cost_usd and duration_ms are None, raises HTTP 502
with diagnostic context (tools, turns, raw_messages, cause hint).
Backend's task_execution_service.py:542 only flags AUTH on 503, so 502
falls through to the generic FAILED path with the helpful detail
preserved — no backend changes needed.
The two-field check is conservative: single-field nullability could be a
Claude format quirk; both-None is a strong signal that the terminal
result message never arrived. Test coverage pins the scope so a future
edit can't silently broaden it.
Changes:
- docker/base-image/agent_server/services/claude_code.py — new
_classify_empty_result() helper next to _classify_signal_exit; call
site between the return_code != 0 block and response building.
- tests/unit/test_empty_result_classification.py — 9 new tests, all
pass. Covers both-None → 502, populated metadata → None,
single-field-only → None (Claude format quirk tolerance), zero-cost
and zero-duration → None (is None vs falsy), missing metadata → None.
- docs/memory/feature-flows/parallel-headless-execution.md — changelog
entry under Recent Updates.
- docs/memory/feature-flows/task-execution-service.md — row in
error-translation table + new Empty-Result Pre-Check paragraph.
Requires base-image rebuild after merge:
./scripts/deploy/build-base-image.sh
Closes #520
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(feature-flows): index entry for agent error classification (#516, #520)
Combined Recent Updates entry covering the matching pair of agent-side
error-classification fixes that shipped this week — _classify_signal_exit
(#516, PR #517) and _classify_empty_result (#520, PR #521). Both touch
docker/base-image/agent_server/services/claude_code.py and share the
"agent surfaces the right HTTP status so backend records FAILED with a
useful detail" theme.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(agent): off-load synchronous terminate cleanup off the event loop (#523)
The async terminate_execution endpoint and the outer asyncio.TimeoutError
handlers in execute_claude_code and execute_headless_task were calling
registry.terminate() / _terminate_process_group() / _safe_close_pipes()
synchronously. Those helpers do up to 7s of process.wait() (SIGINT grace
+ SIGKILL grace), which blocks the asyncio event loop for the entire
window. While blocked, agent-server cannot serve /health, the backend
circuit breaker opens, and UI fan-out hangs for 5+ minutes per page.
This is the actual user-visible mechanism behind the #523 "agent-server
wedge" symptom, not the FD-inheritance / leaked-reader-thread theory in
the original report (see issue comment for the corrected diagnosis —
the FD_CLOEXEC fix as written would not have helped because dup2 strips
CLOEXEC during the child's stdout setup, and the existing post-#407
killpg + safe_close path actually works in the vast majority of cases).
Wrap the three call sites in loop.run_in_executor(None, ...) so the
blocking process.wait() runs on a thread-pool worker. Pipe-inheritance
fragility remains a slow-burn cleanup item to be filed separately.
- routers/chat.py: terminate_execution dispatches registry.terminate to
the default executor
- services/claude_code.py: outer-timeout cleanup in both async paths
off-loads _terminate_process_group + _safe_close_pipes
- tests/unit/test_terminate_async_executor.py: regression test asserts
registry.terminate runs on a non-event-loop thread and the event-loop
yield stays sub-50ms while terminate is in flight
Fixes #523
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(planning): add Tier 2.6 hardening + actor-model destination roadmap
Records the architectural critique from 2026-04-26 review:
- Tier 2.6 (Sprint D′): #524 state machine contract, #525 idempotency
keys, #526 dispatch circuit breaker. Closes the three contract-level
gaps that survive even after Sprint D's plumbing consolidation.
- Future considerations: 7 unranked recommendations (durable
ProcessRegistry, retry-in-funnel, synchronous terminate ack, dual
streams, fairness, EventBus backpressure, lifecycle contract doc).
- Target architecture section: names the actor model as the destination
(mailbox + journal + processor), maps existing components to the
concepts they already implement, defines a 4-phase gated transition
roadmap, and gates Phase 2 (agent-to-agent experiment) on a one-page
message-envelope + journal-format postcard.
Issues: #524, #525, #526 created and added to project board (Epic
#411 Orchestration Invariants, Theme Reliability).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(planning): mark #291 (WEBHOOK-001) shipped, Sprint C now 5/5
#291 closed 2026-04-24, shipped via PR #484 (token-in-URL trigger
through TaskExecutionService) with follow-up fix PR #493. The plan
doc still listed it as the next item to pick up; align it with the
ground truth and re-aim "what to do next" at #428 (after #306 soak)
plus Tier 2.6 hardening (#524/#525/#526) in parallel.
* refactor(capacity): consolidate three queue/slot primitives into CapacityManager (#428) (#527)
* refactor(capacity): consolidate ExecutionQueue + SlotService + BacklogService into CapacityManager (#428)
Single public facade for agent execution capacity. Composes SlotService
(Redis ZSET counter) and BacklogService (SQL persistent overflow) as
private internals; owns the in-memory overflow store (Redis LIST, depth 3,
lifted from the deleted ExecutionQueue).
Why:
- 7 caller sites now go through one API instead of orchestrating three.
- Each new trigger type (retry, webhook, self-exec, fan-out) gets one path
for capacity, not a choice between three primitives.
- Unblocks #429 (CLEANUP-COLLAPSE) and the actor-model destination by
reducing the surface a single capacity store has to expose.
API:
capacity.acquire(agent, exec_id, max_concurrent, *,
overflow_policy='reject'|'queue_in_memory'|'queue_persistent',
overflow_payload=PersistentTaskPayload(...))
capacity.release(agent, exec_id) # idempotent
capacity.release_if_matches(agent, eid) # TOCTOU-safe (watchdog)
capacity.get_status(agent, max_concurrent)
capacity.reclaim_stale(agent_timeouts) # called by cleanup_service
capacity.force_release(agent) # emergency
capacity.cancel_all_overflow(agent, reason) # agent deletion
capacity.run_maintenance(max_age_hours) # 60s tick from main.py
Wire format unchanged: same Redis keys (agent:slots:*, agent:queue:*),
same SQL columns (schedule_executions.queued_at, backlog_metadata).
In-flight executions unaffected; clean revert path.
Deviations from issue spec (user-approved):
- No feature flag — single runtime path. dev-soak + clean revert is the
rollback mechanism, simpler than a per-agent DB column + flag check at
every call site.
- ExecutionQueue deleted in this PR rather than separate cleanup PR.
SlotService and BacklogService kept as private internals (well-factored,
one job each).
Soak deviation: shipped after 5 days of #306 soak rather than the planned
14 days. Mitigated by additive-style refactor (no wire-format change).
Files:
- NEW services/capacity_manager.py (~480 LOC)
- DELETE services/execution_queue.py (~360 LOC)
- 7 caller migrations: routers/chat.py (4 sites), routers/agents.py (2),
routers/agent_config.py (1), services/cleanup_service.py (4),
services/task_execution_service.py (1), services/agent_service/queue.py (3),
main.py (1, callback wiring is now internal).
- NEW tests/unit/test_capacity_manager.py — 21 tests covering acquire/release
for all three overflow policies, drain wiring, status, force_release,
reclaim_stale, cancel_all_overflow.
- UPDATE tests/test_watchdog_unit.py — 11 mock decorator pairs collapsed to
single get_capacity_manager mock.
Tests: 21 new + 35 watchdog + 33 backlog = 89 green for affected surface.
Fixes #428
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(feature-flows): add capacity-management.md, deprecate predecessor flows (#428)
- NEW capacity-management.md — public surface, overflow policies, end-to-end
/chat and /task flows, storage map, maintenance & recovery, what-replaced-what.
- DEPRECATE notes on the three predecessor flows with redirects:
- execution-queue.md (ExecutionQueue deleted)
- parallel-capacity.md (SlotService internalized)
- persistent-task-backlog.md (BacklogService internalized)
- "Now uses CapacityManager" notes on four downstream flows:
- task-execution-service.md, parallel-headless-execution.md,
cleanup-service.md, execution-termination.md
- Index: Recent Updates row + Core Agent Features row for capacity-management.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(requirements): note BACKLOG-001 is now internal to CapacityManager (#428)
Section 10.8 (Persistent Task Backlog) — replace direct SlotService callback
reference with the unified CapacityManager facade. Status bumped with the
2026-04-26 internalization date and #428 cross-ref.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(agent): drain pipe before close to preserve final result line (#531) (#532)
* fix(agent): drain pipe before close to preserve final result line (#531)
drain_reader_threads previously called safe_close_pipes() immediately
after terminate_process_group(), discarding the kernel pipe buffer before
the reader thread could drain it. On long agentic tasks the final
{"type":"result"} JSON line (cost, duration, answer) was in that buffer
at the moment of close, causing the reader to raise ValueError and
metadata.cost_usd / duration_ms to remain None — triggering the HTTP 502
"Execution completed without a result message" classification from #521.
Fix: reorder so grandchildren are killed first, then the reader is given
post_kill_grace=30s to drain naturally (grandchildren dead → kernel
delivers EOF once the buffer is consumed → reader returns '' and exits
cleanly). safe_close_pipes() is now a true last resort — only called when
the reader is still alive after 30s, which indicates a genuine wedge, not
unfinished backlog drain.
Also extends _classify_empty_result to derive num_turns from raw_messages
when metadata.num_turns is None (result line lost), so the 502 detail
reports an honest turn count instead of always showing 0.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* docs(tests): update test catalog for #531 drain_reader_threads fix
- Add test_subprocess_pgroup.py and test_empty_result_classification.py
to Test Categories (Operations & Observability, unit section)
- Add 2026-04-27 Recent Test Additions entry with description of the
pipe-drain ordering regression tests and raw_messages fallback tests
- Update unit test count: 165 → 170; total: 2,257 → 2,262
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* docs(feature-flows): document drain_reader_threads pipe-ordering fix (#531)
Update parallel-headless-execution.md with the root cause fix for
the "Execution completed without a result message" HTTP 502: the old
drain_reader_threads sequence closed the pipe before the reader could
drain the kernel buffer (including the final result JSON line). New
sequence: kill grandchildren → natural drain (post_kill_grace=30s) →
force-close only as last resort.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(tests): update orphaned-recovery mocks to CapacityManager (#533) (#534)
Replace stale services.slot_service sys-mock with services.capacity_manager
so all four recovery scenario tests pass after the #428 consolidation.
Assertions updated from release_slot → release to match the new API.
Fixes #533
Co-authored-by: Claude <noreply@anthropic.com>
* docs(skills): align validate-pr with DEVELOPMENT_WORKFLOW.md
Add quick triage block, base branch check, PR size warning, type-docs
label, Base Branch/PR Size rows in report table, and review pipeline
matrix linking /review and /cso --diff with their complementary roles.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(validate-architecture): stale-citation filter + dedupe guard (#511) (#513)
* fix(validate-architecture): add stale-citation filter + issue dedupe guard (#511)
The /validate-architecture skill produced false-positive issue #479 by:
1. citing file paths the report's snapshot saw, but `main` no longer has
(process engine deleted in #430 the same day);
2. running `gh issue create` with no check for existing open issues with
the same finding fingerprint.
Two targeted edits to .claude/skills/validate-architecture/SKILL.md:
- New Step 2c "Filter Stale Citations" — `git ls-files --error-unmatch`
every cited path before report. Drop ghosts. Downgrade FAIL → PASS
when an invariant has zero remaining real citations.
- Modified Step 4 — fingerprint = sorted invariant numbers; query
open `automated,priority-p1` issues with `--search "in:body
validate-architecture fingerprint=<fp>"`; comment on existing
issue instead of creating duplicate. Issue body now stamps the
current commit SHA for evidence binding.
Closes #511.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(validate-architecture): clarify dedupe branching, distinct fingerprint marker, quote paths (#511)
Follow-up to review feedback on PR #513. Three small skill-prose
hardenings:
- I2 (LLM-driven flow control): the dedupe branch previously relied on
`if [ -n "$EXISTING" ]; then ...; exit 0; fi` followed by a separate
create block. `exit 0` halts a bash subshell, not an LLM walking the
markdown — a future runner could execute both blocks. Replace with
explicit "Path A — COMMENT, then STOP" / "Path B — CREATE" prose
branching and an explicit DO-NOT note.
- I4 (fingerprint collision): replace free-text body search
`validate-architecture fingerprint=$FP` with HTML-comment marker
`<!-- validate-architecture::fingerprint=$FP -->` plus a quoted-phrase
search. Self-evidently programmatic; won't collide with prose.
- I3 (path quoting): the Step 2c example now uses `"$path"` and a note
about shell metachars, so implementers don't strip the quotes.
- Add concurrency caveat documenting that the dedupe is best-effort,
not atomic (no GitHub primitive provides this).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(config): clean stale Auth0 / AUDIT_URL, document SMTP/SendGrid/FRONTEND_URL (#481) (#509)
- Remove dead Auth0 env vars + build args from docker-compose.prod.yml
and docker/frontend/Dockerfile.prod (Auth0 removed 2026-01-01). The
build-arg fallbacks were also leaking a real Auth0 domain + client ID
into a public repo.
- Drop AUDIT_URL from .env.example (audit-logger service no longer exists;
no Python references it).
- Add FRONTEND_URL to .env.example (required in prod for OAuth post-auth
redirects in slack_service.py / public_links.py and SSH host
auto-detection in ssh_service.py).
- Document SMTP_HOST/PORT/USER/PASSWORD and SENDGRID_API_KEY in
.env.example so the advertised EMAIL_PROVIDER=smtp/sendgrid modes are
actually configurable from the template.
Closes #481
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(auth): require auth on /api/docs endpoints (#452) (#507)
Add Depends(get_current_user) to the three handlers in
src/backend/routers/docs.py so the file no longer violates
Architectural Invariant #8.
Note: this router is not currently registered in main.py, so
the endpoints are not reachable on the running API. The fix is
applied to the file as written so the invariant validator stops
flagging it and so the file is correct if it is ever remounted.
Closes #452
* fix(chat): wrap long unbroken strings in chat bubbles (#457) (#502)
Long URLs, tokens, base64 blobs, and other unbroken strings in agent
chat responses were overflowing their 85% bubble and forcing horizontal
scroll on the entire Chat tab.
Root cause: ChatBubble.vue capped the bubble width but never told
inner content how to handle unbreakable strings. Inline <code> and
the user-text <p> had no overflow-wrap; <pre> defaulted to white-space:
pre with no overflow-x: auto override.
Fix (CSS-only, all 3 render branches — user / self-task / assistant):
- min-w-0 on outer wrapper, overflow-hidden on inner bubble
- break-words on user text and prose container
- prose-pre:overflow-x-auto + prose-pre:max-w-full so code blocks
scroll inside the bubble instead of expanding it
- prose-code:break-words for long inline tokens
- prose-a:break-words for long URLs in markdown links
Verified visually: before/after static test page shows BEFORE leaks
content well past the bubble border; AFTER wraps cleanly with no
regression on normal markdown (headings, lists, links, short code).
* fix(schedules): add missing Request import for webhook endpoints (#493)
Regression from c630931 (WEBHOOK-001 / #291): `routers/schedules.py`
uses `Request` as a type annotation on the `generate_webhook` and
`trigger_webhook` handlers but never imports it, so the module fails
to load and the backend won't start with a NameError.
Minimal fix: add `Request` to the existing `from fastapi import …`
line (line 11). No behavioral change — the annotation was already
intended.
Surfaced while dev-testing FILES-001 (PR #491). uvicorn reload pulled
in the dev branch state and blew up.
Co-authored-by: Pavlo <pash@pashs-MacBook-Pro.local>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(git): route orphan cleanup through db.delete_git_config (#451) (#501)
Replaces raw `DELETE FROM agent_git_config` SQL in routers/git.py with the
existing `db.delete_git_config()` method (already used at line 435 of the same
file for the init-failure rollback path). Restores Architectural Invariant #1
(Three-Layer Backend) for this router. No behavior change — identical SQL,
identical parameter binding.
Closes #451
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(subscription): auto-switch on first failure + auth-class triggers (#441) (#508)
Drop the 2-consecutive-429 gate in `subscription_auto_switch` so a single
subscription failure now triggers a switch — the 2h skip-list on
alternative selection (already pinned by #444 / #476 regression tests) is
sufficient as the lone thrash guard. Broaden the trigger surface to also
fire on auth-class failures (401/403/credit balance/expired OAuth token,
etc.), classified via a centralized `AUTH_INDICATORS` list, so a broken
subscription auto-recovers instead of failing every execution until
manual intervention. Flip the `auto_switch_subscriptions` default to
"true" — operators can still opt out, but the safe behavior is now the
default. Backward-compat shim `handle_rate_limit_error` preserved for
existing 429 callers.
- services/subscription_auto_switch.py: new `handle_subscription_failure`
with `failure_kind` dispatch ("rate_limit" | "auth"); new
`is_auth_failure` classifier; default flipped; notification + log
wording adapts per kind; old shim retained.
- services/task_execution_service.py: 503 / auth-classified errors now
also call the switch path alongside 429.
- routers/chat.py (sync): same broadening on the interactive chat
surface; auth path returns 503+retry hint mirroring the 429 UX.
- routers/subscriptions.py: GET `/auto-switch` default also flipped to
"true" so the UI toggle and runtime gate read the same value.
- scheduler/service.py: dedupe two inline `auth_indicators` copies into
a single module-level constant; cross-reference the canonical list in
backend (cross-container import not viable).
- tests/unit/test_subscription_auto_switch_pingpong.py: new
TestIsAuthFailure + TestSingleEventThreshold classes (8 new tests, all
pingpong + #476 aging tests still green).
- tests/test_subscription_auto_switch.py: flip default-off → default-on.
- docs: SUB-003 feature flow + requirements doc reflect the new
threshold, broadened scope, and on-by-default behavior.
Closes #441
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(feature-flows): clean up #95 drift missed by #500 (#496) (#503)
* fix(backlog): repair drain spawn after #95 rename (#496)
`services/backlog_service.py:_spawn_drain` was lazy-importing
`_execute_task_background` from `routers.chat`, but #95 (PR #316) deleted
that function and replaced it with `_run_async_task_with_persistence`.
Every backlog drain raised `ImportError`, was caught at line 218-228, and
silently marked queued executions FAILED — leaving BACKLOG-001 (#260)
non-functional whenever an agent hit capacity.
Rewire the lazy import to the new helper and adjust the call shape:
- drop `task_activity_id` (not in new signature; chat router already
passes None at enqueue)
- drop `release_slot=True` (the wrapper passes `slot_already_held=True`
to TaskExecutionService, which manages release in its finally block)
- derive `is_self_task` from x_source_agent vs agent_name
- pass `self_task_activity_id=None` (queued items don't carry one;
separate gap, not in scope here)
Add `tests/test_backlog_drain_unit.py` with five regression checks:
two AST-based contract tests that pin the function name and signature
in `routers/chat.py` (would have caught the original break), and three
runtime spy tests covering the kwarg shape `_spawn_drain` forwards. The
existing `tests/unit/test_backlog.py::test_drain_happy_path_spawns_background`
is updated to match the new contract.
Sync the BACKLOG-001 and TaskExecutionService feature-flow docs to
reference the renamed helper.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(feature-flows): sync index + parallel-capacity for #496
- Add #496 entry to feature-flows.md Recent Updates.
- Fix two more stale `release_slot=True` references in
parallel-capacity.md left over from #95 — the param never existed
on `_run_async_task_with_persistence` (slot release happens inside
TaskExecutionService via slot_already_held=True).
Other stale `release_slot=True` references in
authenticated-chat-tab.md and parallel-headless-execution.md are
deeper drift (separate flows, not touched by #496) — leave for a
follow-up doc-cleanup pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(catalog): register test_backlog_drain_unit.py (#496)
Adds the new BACKLOG-001 regression test file to tests/registry.json
so it shows up in the catalog alongside test_event_bus.py and
unit/test_backlog.py.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test: drop redundant test_backlog_drain_unit.py
PR #500 (which superseded the original #496 fix scope) shipped
equivalent contract coverage with a more robust setup:
- `TestLazyImportTarget` (AST guard for the lazy-import target)
- `test_drain_threads_self_task_fields` (round-trip via real
BacklogService against sqlite)
The local file used sys.modules stubs which were strictly weaker.
Keeping it would only add maintenance burden for duplicate coverage,
so drop the file and its registry entry. Net effect on PR #503 is
that it becomes a small, focused docs-cleanup PR (parallel-capacity.md
and task-execution-service.md drift from #95, plus the missing
Recent Updates entry for #496).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(deploy): align scripts, configs, and docs with production operating patterns (#504)
* docs(generate-user-docs): add hub-and-spokes deployment structure and ops-pattern import
Restructure skill to produce guides/deploying/ as a hub plus six spokes
(local-development, single-server, public-access, upgrading,
backup-and-restore, monitoring) instead of one flat deploy guide.
Add an "operational guide" template (When to Run → Pre-flight →
Procedure → Verify → Rollback) for procedural docs that don't fit the
feature-shaped dual-audience template, plus verbatim-reuse snippets for
the load-bearing rules: never down/up, rebuild platform services only,
six-probe verification, resource-thresholds table, alpine cp backup.
Add Step 2h to draw operational patterns from the private ops runbook
under ../trinity-ops/, with explicit safe/forbidden import lists and a
sshpass→localhost rewrite rule.
Strengthen Step 2e to cross-check .env.example keys against each
compose's environment block — docs must not promise behavior the
chosen compose can't deliver.
Add public-safety greps in Step 7 (sshpass, trinity-ops, tailnet, real
IPs, instance-dir refs) so leaked private detail blocks completion.
Tracks issue #504.
* fix(deploy): align scripts, configs, and docs with production operating patterns (#504)
Fixes the first-run blocker (agent creation fails silently without base
image) and removes references to the removed audit-logger service that
caused verify-platform.sh and validate.sh to always fail.
Scripts:
- start.sh: detect missing base image and auto-build on first run; use
`docker compose stop` in help text (not `down`, which destroys agents)
- verify-platform.sh: full rewrite — remove trinity-audit-logger and port
8001 audit checks; fix frontend from port 3000 → 80; check scheduler
health at :8001; add MCP/Vector probes; fix login hint
- validate.sh: remove non-existent `deployment/` dir, `QUICK_START.md`,
and `src/audit-logger/audit_logger.py` from required paths; fix port 3000
Config:
- docker-compose.yml: wire 5 missing env vars into backend (PUBLIC_CHAT_URL,
FRONTEND_URL, EXTRA_CORS_ORIGINS, SLACK_SIGNING_SECRET, SSH_HOST)
- .env.example: remove stale AUDIT_URL; annotate prod-only / overlay-only
vars (SLACK_SIGNING_SECRET, PUBLIC_CHAT_URL, FRONTEND_URL, SSH_HOST,
TRINITY_GIT_BASE_URL) so users know scope before setting them
Docs:
- deploying-trinity.md: add explicit build-base-image.sh step; fix
/trinity:connect to use MCP API key flow (not username/password); add
Upgrading, Health Verification, Resource Thresholds, and Common Recovery
Patterns sections from ops runbook; use `docker compose` (v2 syntax)
- setup.md: remove false claim that start.sh builds the base image; correct
admin account creation (env var driven, not wizard); clarify wizard path
(used only when ADMIN_PASSWORD is unset)
New file:
- quickstart.sh: interactive one-command setup (checks Docker, generates
secrets, sets ADMIN_PASSWORD, builds base image, starts services, verifies)
Skill:
- generate-user-docs: add deployment config reading rules (read scripts
literally; cross-check env vars vs compose; never claim "auto" unless code
proves it); add operational guide template (pre-flight/steps/verify/rollback);
resolve conflict preserving the hub+spokes guide structure from branch
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(generate-user-docs): remove private repo name from SKILL.md
Replace explicit `trinity-ops` repo references with generic path aliases
(`../ops-runbook/`) so the private repo name is not embedded in this
public repository.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(reliability): CAS guards on execution status writes + state machine doc (#524) (#541)
Closes the FAILED→SUCCESS and SUCCESS→FAILED races that were patched by
#378 re-verify logic without eliminating the root cause.
Changes:
- update_execution_status: SUCCESS writes are unconditional (agent wins);
non-success terminal writes blocked when row already terminal
- mark_stale_executions_failed / mark_no_session_executions_failed: inner
UPDATE gains AND status='running' to close the SELECT→UPDATE TOCTOU window
- _recover_execution: routes through mark_execution_failed_by_watchdog
(already CAS-guarded) instead of bare update_execution_status
- TaskExecutionStatus: state machine, transitions, and authorized writers
documented in docstring; PENDING_RETRY added to enum
- Remove now-dead _STALE_SLOT_ERROR_PATTERN constant
Full projector architecture (ExecutionStateProjector, agent event emission,
projected_status shadow column) deferred — agents have no Redis access and
the restart-recovery design needs more thought before those land.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(public-chat): build context before storing user message to prevent duplication (#539) (#540)
* fix(public-chat): build context before storing user message to prevent duplication (#539)
In the public chat endpoint, the user message was persisted to the database
before build_public_chat_context read from it, causing the current message
to appear twice in every agent prompt — once in "Previous conversation:"
and once in "Current message:". Reordering the calls so context is built
first (from prior history only) then the user message is stored eliminates
the duplicate on every turn.
Adds unit tests that document both the old broken order (two occurrences)
and the corrected order (one occurrence), guarding against regression.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* docs(feature-flows): update public-agent-links with #539 context ordering fix
- Correct PUB-005 data flow: build_public_chat_context before add_public_chat_message
- Update backend implementation step ordering to match fixed code
- Add revision history entry for the bug fix
- Add #539 entry to feature-flows.md index
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(agent-runtime): guard content_block isinstance in process_stream_line (#542) (#543)
Prevents AttributeError crash when Claude Code stream-json emits a
string element inside a message content array. Guards both
process_stream_line (real-time path) and parse_stream_json_output
(batch path) using the same isinstance(block, dict) pattern already
used by the error_content loop.
Fixes #542
Co-authored-by: Claude <noreply@anthropic.com>
* docs(#411): Phase 1 canary harness design + catalog Phase 1 subset additions (#544)
* docs(#411): Phase 1 canary harness design + catalog Phase 1 subset additions
- New design doc at docs/planning/CANARY_HARNESS_PHASE_1.md scoping the
AC-required infrastructure (snapshot collector, canary_violations table,
canary agent template, fleet, alerts) for the three required invariants
(S-01, E-02, L-03).
- Catalog Phase 1 subset expanded 10 → 12: adds S-03 (slot TTL ≥ exec
timeout, catches #226) and E-05 (dispatched rows have session, catches
#106), since both bugs are cited in the catalog motivation but had no
Phase 1 detector.
* docs(#411): scope fleet to strict minimum for AC's 3 invariants
* docs(#411): expand design doc to cover full Phase 1 (12 invariants, snapshot format)
* feat(settings): add Remove buttons for stored API keys + Slack (#459) (#483)
Settings page lets admins save/test Anthropic API Key, GitHub PAT, and
Slack OAuth credentials, but exposed no UI to clear them once stored.
Only workaround was calling DELETE endpoints directly or editing the DB.
Adds Remove buttons next to Save in each row, conditionally rendered
when the value lives in settings DB (source === 'settings'). Env-var
fallbacks stay uneditable from UI. Confirm dialog before deletion
(reuses ConfirmDialog component + pattern from ApiKeys.vue).
Backend DELETE endpoints already existed — no backend work:
- DELETE /api/settings/api-keys/anthropic
- DELETE /api/settings/api-keys/github
- DELETE /api/settings/slack
Audit of other Settings sections: Trinity Prompt has clearPrompt,
Skills Library blanks via deleteSetting, MCP URL has resetMcpUrl,
GitHub Templates/Email Whitelist have inline remove. Agent Quotas are
config values, not secrets.
Closes #459.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(migrations): swallow duplicate-column race on cold start (#456) (#537)
* fix(migrations): swallow duplicate-column race on cold start (#456)
`_migrate_sync_health` (#389) used a check-then-act PRAGMA → ALTER
pattern that is not atomic across uvicorn workers. On cold start with
`--workers 2`, both workers passed the PRAGMA before either committed
the ALTER, and the loser crashed its child process with
`sqlite3.OperationalError: duplicate column name: auto_sync_enabled`.
Fix:
- Add `_safe_add_column` helper that swallows the duplicate-column
OperationalError (treats it as success — another worker won the race).
Future migrations should route ALTER TABLE ADD COLUMN through it.
- Refactor `_migrate_sync_health` to use the helper for both column
additions and switch the bare `CREATE TABLE` to `CREATE TABLE IF NOT
EXISTS` (atomic in SQLite).
Tests:
- `test_safe_add_column_swallows_duplicate_column_race` — drives the
exact production race via a PRAGMA-lying cursor proxy.
- `test_safe_add_column_propagates_other_errors` — non-duplicate errors
still raise.
- `test_safe_add_column_returns_true_when_added` — happy path.
- `test_migrate_sync_health_idempotent_under_race` — `_migrate_sync_health`
is now safe to re-run on already-migrated schemas, including under
the simulated race.
The other ~50 ALTER ADD COLUMN sites are untouched: they're already
applied on production DBs (run_all_migrations short-circuits via the
schema_migrations tracking table). The race only bites new migrations
on first cold-start; the helper is available for them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(migrations): route all ALTER ADD COLUMN through _safe_add_column (#456)
Mechanical sweep of every check-then-act `PRAGMA table_info` →
`ALTER TABLE ADD COLUMN` site through the `_safe_add_column` helper, so
new migrations on a fresh cold-start with `--workers N` are race-safe by
default — not just `_migrate_sync_health` (the originally reported case).
22 migrations refactored. Bare `try/except Exception` swallows in
`_migrate_chat_messages_source_column` and
`_migrate_agent_ownership_voice_prompt` are also replaced with the
helper, which catches only the duplicate-column error instead of every
exception class.
Verification:
- Schema dump (init_schema + run_all_migrations on fresh DB) is
byte-identical before and after the sweep — every column type,
default, FK, and index preserved.
- run_all_migrations is idempotent across runs and across fresh
connections (2nd/3rd runs print no add/create lines).
- 6-worker concurrent stress test (`threading.Barrier`-coordinated)
completes without any worker crashing; final schema is intact.
- tests/unit/test_migrations_concurrent.py +
tests/unit/test_migrations.py + tests/unit/test_guardrails.py:
72 pass, 0 fail.
`tests/unit/test_guardrails.py::test_migration_is_idempotent` updated
to also exec the `_safe_add_column` helper into its isolated
namespace, since the migration now delegates to it.
The two remaining bare `CREATE TABLE` calls in the file
(`_migrate_agent_sharing_table`, `_migrate_agent_skills_table`) are
one-time DROP+CREATE data-recreation migrations that already shipped
on every existing install; they are out of scope for this sweep.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(git): UI Push no longer commits runtime state (#462)
Expands the platform .gitignore deny-list to cover all runtime files
(.env, .mcp.json, .credentials.enc, instance dirs, content/, Claude
Code state, temp files). Adds idempotent migration that updates
existing agents on next Push and calls `git rm --cached` for files
that are now tracked but newly ignored.
Closes #462
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(frontend): semantic status color tokens (#67) (#553)
Introduce 5 semantic status tokens (`status-success/warning/danger/info/urgent`)
in tailwind.config.js as direct aliases of the green/yellow/red/blue/orange
palettes, then migrate 9 frontend files (4 components, 2 panel-local helpers,
1 composable, 1 utility) from raw color classes to the new tokens. Visual
output is byte-equivalent — tokens compile to identical RGB values.
Add CI safety net: `npm run check:tokens` script verifies token-palette
equivalence and catches typo'd token references in source. Wired into a new
frontend-build.yml workflow that runs `npm ci → check:tokens → build` on PRs
touching `src/frontend/**`.
Drive-by fix: rename postcss.config.js → postcss.config.mjs to fix Node
ESM/CJS interop for local `npm run build` (production Docker build was
masking the issue).
70+ raw-color files remain for follow-up sweep (per autoplan phasing).
Fixes #67
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(files): FILES-001 outbound file sharing — MVP + Phase 1 hardening (#491)
* feat(files): FILES-001 outbound file sharing MVP (Steps 1-6)
Implements outbound file sharing per docs/drafts/amazing-file-outbound.md:
- Schema + migration (agent_shared_files table with FK cascade)
- Per-agent opt-in toggle + Docker publish volume (agent-{name}-public)
- Internal share endpoint with path/MIME/size/quota validation
- Public download endpoint (/api/files/{id}?sig=...) with token auth
- share_file MCP tool (agent-scoped)
- SharingPanel UI: toggle, list, revoke, copy URL
Live-verified on Slack: agent→share_file→URL→download end-to-end.
Unit tests: 33 passed (migration, mixin, mount-match).
Known limitations + production readiness plan:
docs/drafts/amazing-file-outbound-production-readiness.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(files): FILES-001 — requirements, architecture, feature-flow doc
- requirements.md §13.10 new entry marking FILES-001 Implemented (2026-04-24)
- architecture.md: add files.ts MCP module, agent_shared_files_service,
routers/files.py, the 5 new API endpoints + dedicated section, and the
agent_shared_files table schema + operational notes
- feature-flows.md: Recent Updates entry + Documented Flows index
- feature-flows/file-sharing-outbound.md: new full vertical-slice doc
(UI → store → router → service → DB → download) matching the template
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* security(files): cap filename length at 255 chars (C2)
ShareFileRequest.filename and ShareFileMcpRequest.filename get
Field(max_length=255, min_length=1). display_name same cap.
Prevents 10KB+ filename edge cases from agent or attacker.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* security(files): disk-space pre-check before write (C3)
New check_disk_space() helper using shutil.disk_usage('/data').
Refuses writes when /data has less than size_bytes + 500MB free
(HTTP 507 Insufficient Storage). Called before persisting.
Protects shared /data mount — SQLite DB, Vector logs, and log
archives live there too; letting an agent fill the disk causes
platform-wide outage, not just a failed share.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(cleanup): purge expired and old-revoked shared files (C4 / Step 7)
Adds delete_expired_and_revoked(revoke_grace_hours=24) to the DB ops
class (returns stored_filename list for disk unlink) + facade forward
+ wired into cleanup_service.py's 5-min tick.
Per cycle:
- SELECT rows where expires_at < now OR revoked_at < now - 24h
- DELETE them from DB
- unlink each /data/agent-files/{stored_filename}
- bumps CleanupReport.shared_files_purged
The 24h grace on revoked rows keeps them queryable for incident
diagnosis right after revocation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* security(files): dedicated rate-limit bucket for downloads (C5)
/api/files/{id} now uses _check_file_download_rate_limit which keys
redis by file_downloads:{ip} instead of sharing the public_link_lookups
bucket used by /api/public/chat and friends. Limits unchanged (60/min
per IP).
Prevents heavy download traffic from starving the rate-limit quota for
public chat or other /api/public/* endpoints on the same IP.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(files): HEAD handler mirroring GET validation (C6)
Link previewers (Slackbot, Twitterbot, Discordbot, facebookexternalhit)
HEAD-probe URLs before GET. Our endpoint was 405-ing those.
Extracted _validate_download_request() helper from GET; new HEAD
handler reuses it and returns Response(200) with the same headers
(Content-Disposition, nosniff, no-store, Content-Length) but no body,
no download counter bump, no audit row. Follows RFC 7231 §4.3.2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* security(files): tighten list endpoint to owner+admin only (C7)
GET /api/agents/{name}/shared-files previously used can_user_access_agent
(owner/admin/shared). But the list response includes full download URLs
with signed tokens — so anyone able to see the list can reuse every
share. That's the same capability as share_file + revoke, both of which
already require can_user_share_agent.
Change to can_user_share_agent (owner + admin), 403 otherwise.
DELETE was already owner-only; no change needed there.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(prompt): agent nudge for share_file MCP tool (C8)
Add a new 'Sharing Files with Users' section to the system-wide
PLATFORM_INSTRUCTIONS between Collaboration and Operator Communication.
Tells every agent:
- write files to /home/developer/public/
- call share_file MCP tool with the relative filename
- return the URL as-is
This means new agents discover the capability without the user
needing to name the tool explicitly. Applies immediately to every
agent via compose_system_prompt() — no image rebuild needed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(pr-491): address validation findings — PII redaction + scope drift
PR #491 /validate-pr flagged two issues:
1. CRITICAL: pavshulin@gmail.com in two draft docs' Owner fields
(amazing-file-outbound.md, amazing-file-outbound-production-readiness.md).
Public repo + CLAUDE.md forbids real user emails.
→ Replaced with @pavshulin (GitHub handle).
2. WARNING: .claude/settings.json committed as new file — personal
Claude Code permission allowlist unrelated to FILES-001 scope.
→ Merged 8 allowlist entries into .claude/settings.local.json
(gitignored per .gitignore:67). Removed .claude/settings.json.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test: fix test-ordering contamination from FILES-001 mixin fixture
Two adjustments surfaced by running the full unit suite:
1. test_file_sharing_mixin.py registered `sys.modules['db']` as a plain
module (no `__path__`), which poisoned `from db.X import Y` lookups in
sibling tests (e.g. test_fleet_sync_audit did `from db.schedules ...`
and hit `'db' is not a package`). Now we give our stub a `__path__`
pointing at the real db directory, and restore `sys.modules['db']` on
fixture teardown so no leakage remains.
2. test_start_agent_skip_inject.py didn't mock the new
`check_public_folder_mount_matches` import added by FILES-001 in
services/agent_service/lifecycle.py. The Mock container lacked
iterable `attrs["Mounts"]`, blowing up with TypeError. Stubbed the
whole `file_sharing` submodule and bound the check on `_mod`
per-test to return True by default.
Full unit suite now matches dev baseline: 17 pre-existing failures,
701 passing (+33 over dev, all from FILES-001).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Pavlo <pash@pashs-MacBook-Pro.local>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(channels): deliver images as vision content blocks via stream-json (#562) (#566)
Replaces the broken base64 data-URI-in-text approach (where Claude Code
received images as opaque markdown strings) with proper vision content
blocks fed via --input-format stream-json stdin. Images sent through
Telegram (and other channel adapters) are now visible to the agent.
- message_router: _handle_file_uploads returns 4-tuple (added image_data);
image MIME files collected as {media_type, data} dicts instead of embedded
- task_execution_service: execute_task() accepts images param, forwards in payload
- agent_server models: ParallelTaskRequest.images field added
- agent_server chat router: passes images to runtime.execute_headless()
- claude_code: adds --input-format stream-json and builds JSON content-block
stdin payload when images present; stdout/stderr threads start before stdin
write to prevent pipe deadlock; write moved into executor (not event loop)
- runtime_adapter ABC + GeminiRuntime: images param added to prevent TypeError
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(frontend): add state, brand, accent token families (#555) (#561)
Extends the design-system token system from #67 with three additional families
for colors that don't fit the status taxonomy:
state-* agent operating modes (autonomous, locked)
brand-* third-party product identity (claude, gemini)
accent-* decorative highlights named after the literal color so future
accents (accent-green, etc.) join cleanly
New tokens (all alias full Tailwind palettes, identical visual output):
state-autonomous → amber (AutonomyToggle AUTO mode)
state-locked → rose (ReadOnlyToggle ON mode)
brand-claude → orange (RuntimeBadge for Claude Code)
brand-gemini → blue (RuntimeBadge for Gemini CLI)
accent-purple → purple (DashboardPanel widget badges)
Also extends scripts/check-design-tokens.mjs to validate the new families
via a KNOWN_FAMILIES map; the reference scanner now flags typos within any
of the four families (status/state/brand/accent), not just status-*.
Migrates the 4 components blocked by #67's status-only scope:
- RuntimeBadge.vue → brand-claude, brand-gemini
- AutonomyToggle.vue → state-autonomous
- ReadOnlyToggle.vue → state-locked
- DashboardPanel.vue → accent-purple slot in getStatusColors
Fixes #555
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(scheduler): agent-owned pre-check hook (#454) (#455)
* feat(scheduler): agent-owned pre-check hook (#454)
New optional contract: agents implement POST /api/pre-check in their
container; scheduler calls it before firing a cron-triggered chat.
Endpoint absent or any error → fire as usual (fail-open). fire=false
records a skipped execution. fire=true with a message overrides the
schedule.message for that invocation.
- docker/base-image/agent_server/routers/pre_check.py: new router that
dynamically loads /home/developer/.trinity/pre-check.py (template-
supplied) and calls its check() function
- agent-server main.py: mount pre_check_router
- scheduler/agent_client.py: pre_check() method with fail-open semantics
on 404/5xx/timeout/malformed-response
- scheduler/service.py: _run_pre_check + pre-check branch in
_execute_schedule_with_lock (cron only; manual triggers bypass)
- tests/scheduler_tests/test_pre_check.py: 12 tests covering client-
and service-level behavior; 161/161 scheduler suite passes
Zero schema change — reuses existing ExecutionStatus.SKIPPED and
create_skipped_execution. Closes the "wake agent on every cron tick"
cost gap noted in docs/planning/PR_REVIEWER_AGENT.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(#454): scheduler pre-check feature flow + arch + requirements
- feature-flows/scheduler-pre-check.md: new flow doc with contract,
fail-open semantics, error table, testing summary
- architecture.md: add /api/pre-check to agent-server endpoint list
and pre-check note to Scheduler Service row
- requirements.md: SCHED-COND-001 entry under §10 (Scheduling & Execution)
- feature-flows.md: index row
- docs/planning/PR_REVIEWER_AGENT.md: design doc from which this
feature was extracted — committed for traceability
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* review: address PR #455 review feedback
- pre_check.py: asyncio.get_event_loop() → get_running_loop() (deprecated in 3.10+)
- pre_check.py: oversized message override no longer dropped silently —
response now carries message_truncated="override dropped: N bytes exceeds
32000 cap" so scheduler/operator can see what happened; log escalated to
ERROR with size+limit details
- pre_check.py: module-level docstring expanded to note the security scope
of check() (full Python interpreter access, same sandbox as chat tools —
operators should review .trinity/pre-check.py like any executable template
file) and the intentional no-cache behavior
- tests/unit/test_pre_check_router.py: 15 new router/unit tests covering
oversized-message drop path and non-dict return → 500 (both previously
only exercised by inspection). Uses importlib to load pre_check.py
directly, avoiding python-multipart requirement from sibling routers
- feature-flows/scheduler-pre-check.md: document truncation behavior,
security scope expectation, and updated test summary (12 scheduler +
15 router = 176 total passing)
Lock-scope concern noted in review is not an issue: the skip path returns
from _execute_schedule_with_lock, and the outer _execute_schedule holds
the lock in a try/finally that covers the return. No leak.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(#454): docker exec instead of agent-server HTTP endpoint
Review feedback on #455 flagged that the HTTP-endpoint design introduced
a new system edge (scheduler → agent-server direct) and a novel code-
loading pattern (importlib in a router). Both broke with Trinity's
established convention that all "run something in an agent container"
flows go through `services/docker_service.execute_command_in_container`
— the same primitive used by:
- services/git_service.py (persistent-state allowlist, #384 S3)
- services/ssh_service.py (key provisioning)
- services/agent_service/terminal.py (web SSH)
- routers/system_agent.py (admin exec)
- adapters/message_router.py (Slack file ingest)
- routers/voice.py, monitoring_service.py
This commit swaps the design accordingly.
Changes:
- Delete docker/base-image/agent_server/routers/pre_check.py and its
router registration. No new HTTP surface on agent-server.
- Delete tests/unit/test_pre_check_router.py (router is gone).
- Add src/backend/routers/internal.py →
POST /api/internal/agents/{name}/pre-check. Runs the template-shipped
`.trinity/pre-check.py` via execute_command_in_container. Two-step:
`test -f` for existence, then `python3 .../pre-check.py`. Returns
{hook_present, exit_code, stdout, stderr}. Gated by existing
X-Internal-Secret header (C-003).
- Rewrite src/scheduler/service.py::_run_pre_check to call the backend
endpoint (scheduler no longer opens a direct edge to agent-server).
Translates …
6 tasks
6 tasks
This was referenced Jun 3, 2026
12 tasks
vybe
pushed a commit
that referenced
this pull request
Jun 19, 2026
…kend resource (#1083) (#1273) * feat(exec): apply_result extraction + inert hardened result-callback endpoint (#1083 PR1) PR1 of fire-and-forget dispatch — a pure refactor plus a fully-hardened, fail-closed result-callback endpoint that rejects all live traffic until PR2 sets the durable async marker. Zero behavior change to current execution paths. - Extract TaskExecutionService.apply_result — the single terminal applier shared by the inline sync path and the future result-callback. Moves the SUCCESS and the httpx #678-salvage FAILED terminal writes (sanitize, cost rollup, context, CAS write, activity completion, breaker outcome, optional slot release) behind one normalized TerminalEnvelope. Every side effect is gated on the CAS bool (Codex #1/#12): a CAS-lost write does nothing — no double activity close, breaker churn, or slot drain. Producer-side classification (SUB-003, error-code, timeout-terminate) stays in execute_task. - slot_service.release_slot: gate the BACKLOG-001 drain on the ZREM result so a replayed/no-op release can't admit a backlog row past max_parallel_tasks (Codex #12). Every legitimate drain trigger removes a present member. - POST /api/agents/{name}/executions/{id}/result — agent's own MCP-key auth (mirrors heartbeat authorize_heartbeat) + ownership + a durable async-marker gate (RUNNING rows must carry claude_session_id='dispatched_async', else 409) + idempotent replay (terminal → {replayed:true}) + body-size 413 caps. - db.get_open_activity_id_for_execution: filtered by related_execution_id AND chat/schedule_start AND state='started' so a shared-eid tool_call row can't be cross-closed (Codex #8). mark_execution_dispatched gains async_dispatch=True. - config: DISPATCH_ASYNC flag + ASYNC_DISPATCH_ELIGIBLE_TRIGGERS={schedule,webhook} + dispatch_async_eligible(); compose/.env forwarding (backend-only, default off). Tests: apply_result golden + CAS-gate matrix, callback auth/ownership/replay/ marker/size matrix, DB seams; existing CAS-gate + #678 auto-retry parity green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(exec): backend cutover — async dispatch-and-return + lease reaper (#1083 PR2 lane A) Backend half of the fire-and-forget cutover (agent-server half follows). Inert until DISPATCH_ASYNC=true AND a Claude-runtime agent on a new base image ACKs 202. - execute_task: for an async-eligible trigger ({schedule,webhook}) under DISPATCH_ASYNC, send async_result=true, write the durable 'dispatched_async' marker, and on a 202 ACK return RUNNING/dispatched_async immediately, handing the slot lease to the result callback (skip the `finally` release). Any non-202 response (200 / old image / non-Claude runtime) falls through to today's synchronous handling — the safe mixed-fleet fallback. The runtime gate is enforced agent-side (decision 5). - cleanup_service lease reaper: after fail_stale_slot_execution, tag the FAILED message with the lease_expired code and close the open dispatch activity via the filtered get_open_activity_id_for_execution (the absent fire-and-forget coroutine `finally` no longer closes it). Adds TaskExecutionErrorCode.LEASE_EXPIRED. - Finding 1: _sweep_stale_executions now uses each agent's timeout+SLOT_TTL_BUFFER window (mark_stale_executions_failed gains agent_timeouts+buffer_seconds) instead of the flat 120-min default, so a legitimately-running max-timeout async turn isn't failed ~5 min before the slot reaper / canary E-01. agent_timeouts=None reproduces the prior flat behaviour exactly. Tests: dispatch-return matrix (202→RUNNING/no-release, non-202 fallback, trigger scope), stale-sweep per-agent boundary REGRESSION (timeout+buffer±ε), lease activity close; existing CAS-gate / cleanup / observability tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(agent): async-accept (202) + result-callback report/persist/retry (#1083 PR2 lane B) Agent-server half of the fire-and-forget cutover. When the backend sends async_result=true AND this agent runs the Claude runtime, /api/task accepts with 202 and runs the turn in a detached task that reports the typed terminal to the backend's result-callback endpoint. Inert for non-Claude runtimes / old behaviour (async_result defaults false). - services/result_callback.py: try_spawn_async gates on async_result + Claude runtime + execution_id + callback creds (else the caller runs synchronously — the non-202 fallback). _run_and_report runs the headless turn, builds a typed envelope (success → completed; HTTPException → status-mapped error_code/ terminal_reason, with metadata salvaged from the structured 502 body), persists it atomically to ~/.trinity/pending-results/<eid>.json, and delivers it with capped backoff up to the slot-lease deadline (dispatch + timeout + SLOT_TTL_BUFFER), deleting on a 2xx or a permanent 4xx. A strong-ref _inflight set defeats the asyncio weak-ref GC footgun. - main.py: startup sweep re-sends any envelope left on disk by a crash/restart, so completed work isn't lost to a phantom LEASE_EXPIRED (a late SUCCESS still overwrites it via the backend CAS). - chat.py /api/task: 202 branch before the synchronous path; models.py: ParallelTaskRequest.async_result flag. v1 limitation (T8 / #1201, P2 fast-follow): only the 502 empty-result failure path carries cost/context metadata on the async callback; 504/503 write a null-cost row until execute_headless_task exposes ctx.metadata on those paths. Tests: envelope mapping, eligibility gating, persist/resend roundtrip, and the retry-to-deadline delivery loop (2xx, permanent-4xx no-retry, transient-5xx retry, deadline). agent-server regression subset green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(architecture): document fire-and-forget dispatch subsystem + result-callback endpoint (#1083) architecture.md gains a "Fire-and-Forget Dispatch (#1083)" cross-cutting block (apply_result CAS-gating, durable async marker, callback endpoint, agent-side persist/retry/sweep, lease reaper + stale-sweep buffer fix, v1 boundaries), the result-callback endpoint row, and a pointer from the task_execution_service catalog entry. feature-flows index gains the #1083 Recent Updates row. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(exec): let a late SUCCESS callback correct a reaper LEASE_EXPIRED (#1083, Codex #2) The result-callback's idempotent-replay short-circuit fired for ALL terminal statuses, which would block a genuinely-late SUCCESS callback from reaching the CAS after the lease reaper had already FAILed the row (LEASE_EXPIRED) — directly contradicting the plan's accepted "SUCCESS overwrites a phantom FAILED" behavior. Now only the authoritative terminals (SUCCESS/CANCELLED/SKIPPED) short-circuit as a replay ACK; a FAILED row falls through to apply_result, whose CAS lets a late SUCCESS overwrite it (a duplicate FAILED is harmlessly CAS-blocked → no side-effect re-run). The async-marker gate still rejects a FAILED *sync* row (marker != dispatched_async), so the cross-path guard holds for terminals too. +2 callback tests (FAILED-async fall-through, FAILED-sync 409); architecture.md clarified. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(agent): validate execution_id charset before async dispatch (#1083) Defense-in-depth: result_callback builds a pending-results filesystem path (~/.trinity/pending-results/{execution_id}.json) and the backend callback URL from the backend-supplied execution_id. Add a strict allowlist guard (_SAFE_EXECUTION_ID = ^[A-Za-z0-9_-]{1,128}$) enforced at try_spawn_async — a value outside the token_urlsafe / UUID charset now falls back to synchronous handling and never reaches the path build or the callback URL. Adds parametrized unit tests and archives the CSO --diff audit report for the branch. Refs #1083 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(agent): containment barrier at the path-build sink (#1083, CodeQL) CodeQL py/path-injection flagged _persist/_delete: a caller-side regex guard in try_spawn_async is not recognized as a barrier for the callee sink. Move the sanitizer into _pending_path itself — resolve() + is_relative_to() containment against _PENDING_DIR (mirrors jsonl_recovery), raising ValueError on escape. The regex stays as the belt; this is the suspenders on the path-build dataflow, so a hostile execution_id can never traverse out of the pending dir. Refs #1083 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(agent): use os.path.basename to sanitize the pending-result path (#1083, CodeQL) CodeQL py/path-injection did not accept resolve()+is_relative_to() as a barrier and still flagged the path build in _pending_path/_persist/_delete. Switch to the canonical CWE-022 sanitizer: os.path.basename strips any directory components from execution_id before the join, so the write is confined to _PENDING_DIR. The resolve()+is_relative_to() containment stays as suspenders; the regex guard in try_spawn_async remains the belt. Refs #1083 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(agent): adopt the #950 normpath+startswith path-containment guard (#1083, CodeQL) resolve()+is_relative_to() and os.path.basename were both ignored by CodeQL's py/path-injection model (same dead end #950 hit before switching). Mirror the guard that actually cleared the alert there: os.path.normpath collapses any '..', an inline startswith prefix-check confirms containment under _PENDING_DIR, and the normalized value is flowed downstream to write/replace/unlink. Raises on escape; the try_spawn_async regex belt still rejects such ids upstream. Refs #1083 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Eugene Vyborov <eugene@beingluminous.com>
7 tasks
vybe
pushed a commit
that referenced
this pull request
Jul 6, 2026
… email-request parity + MCP owner-disclosure removal (#186) (#1455) * fix(auth): uniform 404 in agent-access deps + equalize email-request (#186) Tier 1: collapse the four agent-access dependency helpers to a uniform 404 for both non-existent and inaccessible agents. Evaluate existence and access booleans before branching so query-count (hence timing) is equal, and run the connector-scope check before the existence lookup so a connector key gets a uniform 403 across all non-bound names. Tier 2: POST /api/auth/email/request now returns a byte-identical body + status for whitelisted, non-whitelisted, and rate-limited emails, and dispatches the code email fire-and-forget so the whitelisted path's latency matches the immediate-return paths. Over-limit is WARN-logged server-side instead of a 429 (fail-loud for ops, no client-visible membership oracle). Closes the differential-response enumeration oracles from UnderDefense pentest 3.3.3 at the highest-leverage surfaces. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(auth): sweep router-level agent-enumeration oracles (#186) Tier 3 — collapse ad-hoc 404-then-403 sequences that bypass the dependency helpers: - avatar.py generate/regenerate/delete → OwnedAgentByName (uniform 404; now also connector-scope gated, intended hardening). - nevermined.py _require_read/_require_write_access → uniform 404. - event_subscriptions.py create → collapse source-agent 400-vs-403 into a single uniform 403 for cross-agent subscriptions. - schedules.py webhook generate/status/revoke → AuthorizedAgent so the schedule-404 is only reachable by an authorized accessor. Tier 4 — close the open-GET authz hole + 404-vs-200 existence oracle on agent_config.py capabilities/timeout/public-channel-model/guardrails GETs (raw agent_name + get_current_user, no access check) by binding them to AuthorizedAgentByName. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(mcp): remove owner disclosure + uniform agent-access reason (#186) Tier 5 (Invariant #13). chat.ts checkAgentAccess: collapse the 'not found' vs 'owned by X (not shared)' branches into a single uniform 'Agent X not found or not accessible' reason and REMOVE the owner-username interpolation — a user-scoped caller could otherwise learn who owns any agent (the most serious item the review found: a disclosure, not just a status differential). Consumer classifiers adjusted for the backend 403->404 flip: - reports.ts: treat a 404 on the dep-gated report endpoint as not_authorized (alongside 403). - messages.ts: match the dep's exact 'Agent not found' detail as not_authorized before the generic recipient-404 branch, so an agent-access denial isn't misclassified as recipient_not_found. - nevermined.ts: document that an inaccessible agent now reports configured:false (intentional, enumeration-safe). agents.ts needs no change — its agent-scoped permission denials name caller/target only (no owner leak) and are already uniform w.r.t. existence. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(auth): enumeration-uniformity coverage for #186 - New tests/unit/test_186_enumeration_uniformity.py: real-DB (db_harness) assertions that all four agent-access deps return a byte-identical 404 for non-existent vs inaccessible agents (incl. admin-on-nonexistent and owner positive control); real-handler email-request body/no-429 uniformity; and a static guard that the four Tier-4 agent_config GETs now bind get_authorized_agent_by_name. 16 tests, all green. - test_access_control.py: flip the 15 dep-routed owner/read asserts from 403 to 404; admin-gated asserts stay 403. - test_28_voip / test_brain_orb: dep-override denial tests updated to the 404 contract. - test_public_links.py: owner skip-guards tolerate 404. - test_email_auth.py: tighten the unknown-email assert to ==200 (dead 429 branch removed) and assert no expires_in_seconds leaks. Verified in a py3.12 venv: new file 16/16, test_28+test_brain_orb 104/104. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs: record enumeration-uniformity contract for #186 - architecture.md Invariant #8: add the self-uniform (never 404-then-403) rule and the uniform-404 dependency behavior + MCP mirror. - requirements/auth.md §2.1: email-request identical body/status/timing. - requirements/security.md §20.7: new subsection recording #186. - feature-flows/email-authentication.md: revision-history row for the request-code change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(feature-flows): sync #186 enumeration-uniformity contract - feature-flows.md: Recent Updates row for #186. - agent-permissions.md: uniform-404 dependency behavior, MCP checkAgentAccess uniform reason + owner-username removal, refreshed error-handling table + self-uniform contract note. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(auth): nevermined helper enumeration-uniformity + equal-query-count read path (#186) Close the sibling-path gap for the nevermined router's own module-level access helpers (they predate the dependency helpers): - _require_read_access now evaluates existence AND access unconditionally (no short-circuit) so the query count — hence timing — is identical for the non-existent and the existing-but-inaccessible case, matching _require_write_access and the dependencies.py helpers (#186 equal-query-count discipline). - Add real-DB tests asserting both helpers raise a byte-identical uniform 404 for a missing vs inaccessible agent, admin-on-nonexistent still 404s, and the write helper keeps owner-only enforcement (shared reader reads but cannot write) — guards the sibling codepath the dependency-helper tests don't reach. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(avatar): path-component barrier + stabilize #186 enumeration test (#186) Two order-dependent CI failures on this branch, both test/analysis-level (no product behaviour change to the enumeration fix): 1. backend-unit-test regression diff (seed 99999): test_186's _agent_exists probe resolved a truthy leaked Mock. A sibling unit test leaves services/services.docker_service as Mocks in sys.modules; _no_docker's dotted-string monkeypatch landed on a different auto-mock than the one `from services.docker_service import get_agent_by_name` resolves, so a non-existent agent read as existing and the admin-nonexistent 404 never fired. Capture the real modules at collection and pin them into sys.modules (monkeypatch.setitem, auto-restored) before stubbing the probe. Verified green across all three CI seeds. 2. CodeQL: 18 new py/path-injection alerts in avatar.py. Moving the inline db.get_agent_owner() check into the OwnedAgentByName dependency removed the barrier CodeQL saw on agent_name before AVATAR_DIR / f"{agent_name}...". Add _safe_avatar_component(): an explicit single-path-component basename guard at the top of generate/regenerate/delete — defense-in-depth (traversal is not reachable given OwnedAgentByName + charset-restricted names, but the constraint now lives in a callee CodeQL can't trace) with a uniform 404 that preserves the #186 no-existence-oracle contract. +10 barrier unit tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(avatar): normpath+within-root path barrier CodeQL recognizes (#186) The first attempt (_safe_avatar_component, an os.path.basename guard in a helper) did not clear the 18 py/path-injection alerts: basename is not a CodeQL-modeled sanitizer and the guard sat in a callee, so CodeQL saw tainted-in/tainted-out — the same cross-function-barrier blind spot that hiding the check in OwnedAgentByName created. Replace it with _avatar_path(agent_name, suffix): os.path.normpath(join(base, …)) then require the result stay under AVATAR_DIR (the CodeQL SafeAccessCheck barrier documented in the py/path-injection help). A barrier guard severs taint flow even from inside a helper, since the guarded return only runs when contained. Route the 11 path sites in the four flagged functions (generate/regenerate/delete + _generate_emotions_background) through it; the unflagged GET handlers are left as-is to keep this scoped to #186. Produces byte-identical paths for real (charset- restricted) agent names; uniform 404 on escape preserves the no-existence-oracle contract. Barrier tests updated to assert containment/escape. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(avatar): match CodeQL SafeAccessCheck path barrier verbatim (#186) Prior barrier used `full != base and not full.startswith(base + os.sep)`; the `base + os.sep` expression and compound condition didn't match CodeQL's SafeAccessCheck guard model, so the 18 py/path-injection alerts persisted. Align _avatar_path with the exact pattern from the py/path-injection query help: fullpath = os.path.normpath(os.path.join(base, name)) if not fullpath.startswith(base): raise ... Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Andrii Pasternak <anpast31@gmail.com> Co-authored-by: Eugene Vyborov <eugene@beingluminous.com>
This was referenced Jul 14, 2026
Merged
anubis770
pushed a commit
to anubis770/trinity
that referenced
this pull request
Jul 16, 2026
…s (static-render foundation) (abilityai/trinity-enterprise#58) Capability-gated /agents/:name/brain page that renders an agent's live 3D knowledge-graph orb from data the agent produces in its own container. This is the static-render foundation — voice, scope-mutation, KB-write actions, transcript capture, and headless-skill injection are deferred to later epic children. Default OFF, so zero impact on existing agents and the UI. - First-party CSP-clean assets (public/brain-orb/): the verbatim orb's inline module is externalized to orb.js; three/marked/DOMPurify/JetBrains-Mono are vendored locally; it runs under prod CSP script-src 'self' / font-src 'self' with NO nginx change (the Abilityai#979 trap was agent-origin + inline; this is first-party + external). Note bodies are DOMPurify-sanitized (H-005). - Frontend: lazy /agents/:name/brain route (platform-flag beforeEnter guard); AgentBrainOrb.vue thin chrome + same-origin iframe; JWT handed to the iframe via origin-pinned postMessage (never a URL) — no new ticket primitive; Brain tab gated on brainOrbAvailable AND the template.yaml `brain-orb` capability. - Backend: GET /api/agents/{name}/brain-orb/data — AuthorizedAgentByName read proxy via agent_httpx_client (Abilityai#1159), byte pass-through (no re-serialize), flag-gated 404, 503/504/502 error mapping. - Agent-server: GET /api/brain-orb/data streams data.json via FileResponse (agent owns generation — Invariant Abilityai#8; auto-gated by the Abilityai#1159 middleware). - brain_orb_available feature flag (BRAIN_ORB_ENABLED, default OFF). - 10 unit tests (backend proxy branches + agent-server read); architecture.md, feature-flows/brain-orb.md, requirements §46. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
anubis770
pushed a commit
to anubis770/trinity
that referenced
this pull request
Jul 16, 2026
…ink) (abilityai/trinity-enterprise#61) Completes the Brain Orb write surface with the low-risk half of Phase 4: owner/admin-only capture-a-note and link-two-notes. Split from the full Phase 4 during /autoplan (two independent reviews) — run_skill (headless claude -p exec), automatic transcript capture, and transcript injection are deferred to trinity-enterprise#66 (they carry the exec/injection risk and depend on an unproven Gemini-Live-transcription path). Backend: - routers/agent_brain_orb.py: GET /brain-orb/actions + POST /brain-orb/action (OwnedAgentByName). Action verb enum-validated at the boundary (run_skill/ capture_transcript -> 400, never forwarded), body-capped (413), rate-limited per (user, agent, action), audit-logged, Idempotency-Key deduped (Invariant Abilityai#18, key folded per verb; effect_guard doesn't fit — no execution_id). - services/brain_orb_voice_service.py: can_write folds capture_note/link_notes into the LOCKED voice manifest only for owners; shared users keep the read-only Phase-3 manifest. can_write computed in the mint route. - config.py + routers/settings.py: BRAIN_ORB_WRITE_ENABLED kill-switch (default OFF, distinct from BRAIN_ORB_ENABLED) -> brain_orb_write_available. Agent-server (Invariant Abilityai#5 mirror): - routers/brain_orb.py: GET/POST /api/brain-orb/action[s] via the hardened _run_hook over the agent's ~/.trinity/brain-orb/action convention hook (agent owns the write, Invariant Abilityai#8; stdin-only, no shell string). Frontend: - orb.js: rewire initActions/postAction/doCapture/doLink from the dead localhost voice proxy (X-Orb-Token) to the broker + Bearer JWT + Idempotency-Key; un-hide #actions via body.brain-orb-write only after the broker confirms owner + flag + hook; add capture_note/link_notes to ORB_TOOLS; double-submit re-entrancy guard. - orb-trinity.css / AgentBrainOrb.vue / stores/sessions.js: gate the panel on the write flag, relay writeAvailable in the init handshake. Tests: 14 new cases (owner-gate 403, flag-off 404, no-hook 404, enum 400, 413, 429 + claim-release, idempotency replay/409, owner-only voice manifest, agent-server action hook). 62/62 green. Security: /review 0 critical, /cso --diff 0 findings. Docs: requirements §46 FR-8, feature-flows/brain-orb.md Phase 4a, learnings (effect_guard vs Idempotency-Key), cso-diff-2026-07-01 report. Refs abilityai/trinity-enterprise#61 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
anubis770
pushed a commit
to anubis770/trinity
that referenced
this pull request
Jul 16, 2026
…→select loop hardening (trinity-enterprise#102, Abilityai#103) Abilityai#102 — the post-session run was a DETACHED claude -p forked by the agent's action hook: invisible to the platform (no execution row/cost/failure surface — a billing error silently became the "processed" note) and reaped by the agent-server's 30s cgroup orphan sweep (Abilityai#817). The platform now owns the trigger: the action route strips capture_transcript's `process` flag before the hook call and services/brain_orb_postprocess dispatches the agent's configured prompt (Abilityai#73, still agent-owned — Invariant Abilityai#8) via execute_task(triggered_by="voice") — sweep-safe, visible under Executions, failures land as FAILED rows. process_transcript dispatches the same way and never reaches the hook. Every outcome is surfaced in the orb (saved / started / skipped+reason / failed / agent offline), and voice.js always relays the session end so "no dialogue" is distinguishable from a broken pipeline. Abilityai#103 — "add a note, then show it" over voice failed invisibly: real exporters title inbox nodes by file stem, so the auto-select/navigate searching the H1 title missed (normTitle kept hyphens); postAction swallowed FastAPI `detail` errors (a stopped agent read as a silent no-op); navigate_to_note awaited a minutes-long reindex inside the voice tile's 8s tool timeout. Fixes: pending auto-select tracks title AND path stem, normTitle folds hyphens; refreshGraph joins an in-flight run and queues one follow-up instead of dropping; a refresh that doesn't surface the note polls /data briefly (async/net-zero exporters) instead of declaring "up to date"; navigate waits a bounded ~6s then answers honestly and auto-selects on completion; capture success/failure and broker errors (503 "Agent is not running") toast loudly. Validated live on localhost against both hook generations (real cornelius-e2e hooks + template hooks): dispatch → execution row → agent → terminal status with precise diagnostics; idempotent replay returns the same execution with no double dispatch; the previously-failing stem-titled auto-select now lands. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 tasks
4 tasks
AndriiPasternak31
added a commit
that referenced
this pull request
Jul 18, 2026
Post-merge integration fixes for the origin/dev merge: - test_1310_auth_consolidation.py: adopt the linter-recognized `_STUBBED_MODULE_NAMES` + `_restore_sys_modules` escape hatch for the import-time module-eviction (a module-level `sys.modules.pop` no monkeypatch fixture can reach), so `tests/lint_sys_modules.py` passes without a new baseline entry. Behavior unchanged (same three real service modules re-pinned per test). - test_1310_auth_wiring.py: allowlist `chat.execute_parallel_task` — dev's #1083 resume-session check is a compound `role != "admin" AND NOT db.resume_session_belongs_to_user(...)` raising an intentional 404 (session-id enumeration safety, Invariant #8). No shared helper fits (assert_owns_or_admin takes an owner_id and raises 403), so it stays inline like reports.get_report / nevermined._require_*. - test_1578_task_completion_events.py: the update-route owner check moved into dependencies.assert_agent_owner (#1310), so the #1578 reserved-namespace guard test now no-ops the owner gate via the router's own imported binding (module-identity-robust) instead of patching the router db alone. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
vybe
pushed a commit
that referenced
this pull request
Jul 19, 2026
…ne auth wiring (#1310) (#1683) * docs(auth): INV-8 shared imperative-guard family — auth.md §2.7 + Invariant #8 (#1310) Requirements-driven first step (Rule #1) for the INV-8 auth-dependency consolidation. Documents the five leaf helpers (assert_admin, assert_agent_access, assert_agent_owner, assert_owns_or_admin, assert_owns), the preserve-403 rationale (access-first = self-uniform per #186; #1445 precedent), the imperative-vs-path-dependency rule, the assert_agent_owner != delete-authorization footgun, and the permanent exceptions (nevermined/reports intentional-404, sessions compound-404, slack.py deferred). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(auth): characterization suite locking current inline-auth behavior (#1310) Stage 3 — behavioral lock BEFORE any migration (must stay green after). Real-DB db_harness drives the real db.can_user_* predicates (never a mocked auth check — a wholesale-Mock db false-greens a broken gate); only non-auth resource lookups are stubbed. Covers every in-scope site: admin gates, access gates, owner gates, Shape-F owns-or-admin (admin admitted + anti-IDOR binding), public.py strict-self (the admin-non-owner-403 access-widening guard), the agent_files anti-exfil sibling, the schedules #1445 access-before-404 ordering, and the loops composite gate. Asserts exact status + detail + admitted-principal set per site. Also hardens test_186 tier4 to compare the bound access dependency by name, not object identity: the unit conftest pops `dependencies` between tests while already-imported router modules persist, so a co-resident test importing agent_config in an earlier generation left the annotation bound to a stale (no-longer-`is`-equal) helper object. Name comparison proves the same invariant without the re-import fragility. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(auth): add INV-8 imperative-guard family + relocate narrow_to_agent (#1310) Stage 2 — the five leaf helpers in dependencies.py (assert_admin, assert_agent_access, assert_agent_owner, assert_owns_or_admin, assert_owns), each raising 403 and access-first (self-uniform); the agent-name helpers run _enforce_connector_scope first to match the path-dependencies' second fence. No new import edge (dependencies.py already imports db) and no new DB accessor. Also relocate _narrow_to_agent from routers/executions.py to services/agent_service/helpers.py as narrow_to_agent (removing a router->router import edge; reports.py + executions.py repointed). Semantics unchanged. Part B helper-level truth tables added to the characterization suite: connector fence, owner-or-admin, strict-self (no-admin bypass) + owner_id int/int type parity. 39/39 green. Sites are migrated onto these helpers in the next commits. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor(auth): consolidate inline admin gates onto assert_admin/require_admin (#1310) Bucket 4a (admin). Replaces inline `role != "admin"` raises with assert_admin (avatar generate-defaults, nevermined settlement-failures/retry, schedules scheduler-status) and deletes the four router-local `require_admin` imperative dupes (subscriptions/system_agent/ops/settings — ~67 call sites swapped to assert_admin). logs.py's local `require_admin` Depends is replaced by the shared dependencies.require_admin. Exact 403 "Admin access required" preserved (custom avatar detail preserved); assert_admin/require_admin additionally reject connector principals — a no-op tightening (connectors are fenced upstream). _check_sdk() ordering before the nevermined retry admin gate preserved. Characterization suite (39) stays green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor(auth): consolidate inline access/owner gates onto assert_agent_* (#1310) Bucket 4b (access + owner). Replaces inline db.can_user_access_agent →403 with assert_agent_access (agent_config resources/capacity, avatar identity, public_memory, schedules create, notifications ×3, subscriptions auth-status, loops composite fallback) and inline db.can_user_share_agent →403 with assert_agent_owner (agent_config ×6, agent_files ×3, subscriptions ×2, event_subscriptions ×2, messages). Exact 403 + per-site detail preserved. Load-bearing orderings/siblings preserved verbatim: * schedules.create_schedule — access-403 stays BEFORE the is_agent_live 404 and the #929 timeout read (#1445 anti-enumeration order); * public_memory — the execution-404 / execution.agent_name-403 binding stays; * agent_files.share — the anti-exfil agent-scoped-key sibling check kept; * loops._check_loop_access — admin + initiator allow-returns kept, only the can_user_access fallback migrated. Characterization suite (39) stays green; only slack.py (deferred) + the 4 Shape-F voice/chat sites (next bucket) remain flagged by the guard. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor(auth): consolidate Shape-F + strict-self session gates (#1310) Bucket 4c. Migrates the strict-self-or-admin session gates to assert_owns_or_admin (voice_stop / get_voice_panel / chat get-session-detail / close-session) and the public-link session gate to assert_owns (id-only, NO admin bypass — the access-widening guard: an admin who is not the session owner stays 403; mapping it to assert_owns_or_admin would flip that to 200). Anti-IDOR bindings preserved verbatim (the `session.agent_name != name` / `preview.agent_name != name` 403 on the line above each owner gate — the cross-agent-session-read defense, #600). The WS-dict close(4003) handler (voice_ws) and the admin FILTER branches (chat history/session listing) are correctly left inline — they never raise HTTPException. Characterization suite (39) stays green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(auth): static AST guard against inline auth-wiring drift + slack markers (#1310) The durable risk reduction: test_1310_auth_wiring.py forbids, in routers/*.py, a db.can_user_*_agent() call whose negation guards a `raise HTTPException`, and an inline `role != "admin"` deny-If — the shapes the shared dependencies helpers now own. Precise AST matcher (self-tested against planted violations AND the benign shapes: assignments, role=="admin" allow/filter branches, WS close(4003) dict handlers). Per-(file, function) allowlist for the intentional-404 designs (reports.get_report, nevermined._require_*_access) and a `# noqa: inv8` line-exemption for the deferred slack.py sites — so NEW inline auth added to slack.py still trips the guard. slack.py's 10 inline-auth sites carry the `# noqa: inv8` marker (deferred follow-up, coordinate with the channel-adapter owner). Guard is green over the live tree. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(auth): update existing tests for the consolidated auth mechanism (#1310) The INV-8 consolidation moved the inline checks behind dependencies.py helpers, which (a) now read current_user.connector_agent via the connector fence and (b) resolve db.can_user_* through the dependencies module. Existing tests that predated these need small, behavior-neutral fixups: * test_929 / test_1018 / test_117 / test_subscription_reassign — complete the User fakes with connector_agent=None (a real User always carries it — Optional[str]=None; the fakes just predate the field, same convention as the existing agent_name=None). test_subscription_reassign additionally points assert_agent_owner's own `db` global at its fake_db (the owner gate reads dependencies.db, not routers.subscriptions.db). * test_subscription_bola (#182) — asserts the new gate (assert_agent_owner on assign/clear, assert_agent_access on read-only auth-status) instead of the now-moved raw db-call; the behavioral shared-reader-denied case is covered by test_1310_auth_consolidation. No production behavior changed; these lock the same #182/#929/#1445 invariants against the consolidated shape. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(auth): complete more User fakes + dependencies stub for INV-8 (#1310) The consolidated settings admin handlers now flow through assert_admin (which reads current_user.connector_agent). Complete the settings-test User fakes with connector_agent=None (test_85_brain_orb_settings, test_retention_floor, test_1638) — same field a real User always carries. test_telegram_webhook_backfill stubs `dependencies` in sys.modules; add the five new helper names so a router that imports them against the stub resolves. No production behavior changed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(feature-flows): sync role-model + permissions flows for INV-8 (#1310) Fix role-model.md's stale dependencies.py line numbers (ROLE_HIERARCHY 174→503, require_role 177→506, require_admin 158→486, hierarchy check 190→522), add the _reject_connector_principal line to the require_role snippet, and add §1b + a symbol-table row documenting the five imperative auth-guard helpers. agent- permissions.md gains a pointer distinguishing the consolidated router gates from the untouched service-layer inline checks. Feature-flows index gets its Recent-Updates row. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(security): CSO diff report for INV-8 auth consolidation (#1310) PASS — behavior-preserving refactor; no new bypass, no widening, no enumeration oracle. Documents the two-axis analysis (status + admitted-principal set), the public.py strict-self widening guard (assert_owns, no admin bypass), the connector-fence parity tightening, the intentional-404 exceptions, and the static AST drift guard as the durable control. Full unit tier green modulo one pre-existing failure (test_1474, untouched db/schedules.py summary path). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(auth): pin real service modules in char-test against sibling sys.modules leak (#1310) The consolidation suite drives router handler bodies that lazy-import start_agent_internal from services.agent_service (subscriptions admit path). A sibling unit file (test_inject_assigned_credentials) installs a fake services.agent_service package at its own import that persists for the session, so under an adverse collection/execution order the real module was shadowed and the owner-admit path tripped a bare ImportError escaping _raised. Capture the genuine services / services.agent_service / services.docker_service at collection time (evicting any cached fake subtree first) and re-pin all three per test via an autouse monkeypatch.setitem fixture (auto-restores the sibling's stub afterward, so this file stays a well-behaved sys.modules citizen). Verified: 88 passed with test_inject_assigned_credentials collected first. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(feature-flows): correct role-model.md imperative-family line range (#1310) The INV-8 imperative auth-guard family spans dependencies.py 740-801 (assert_admin@740 … assert_owns body end@801), not the stale 744-786. ROLE_HIERARCHY@503 / require_role@506 / require_admin@486 already correct. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(auth): reconcile INV-8 guards with dev after merge (#1310) Post-merge integration fixes for the origin/dev merge: - test_1310_auth_consolidation.py: adopt the linter-recognized `_STUBBED_MODULE_NAMES` + `_restore_sys_modules` escape hatch for the import-time module-eviction (a module-level `sys.modules.pop` no monkeypatch fixture can reach), so `tests/lint_sys_modules.py` passes without a new baseline entry. Behavior unchanged (same three real service modules re-pinned per test). - test_1310_auth_wiring.py: allowlist `chat.execute_parallel_task` — dev's #1083 resume-session check is a compound `role != "admin" AND NOT db.resume_session_belongs_to_user(...)` raising an intentional 404 (session-id enumeration safety, Invariant #8). No shared helper fits (assert_owns_or_admin takes an owner_id and raises 403), so it stays inline like reports.get_report / nevermined._require_*. - test_1578_task_completion_events.py: the update-route owner check moved into dependencies.assert_agent_owner (#1310), so the #1578 reserved-namespace guard test now no-ops the owner gate via the router's own imported binding (module-identity-robust) instead of patching the router db alone. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Eugene Vyborov <eugene@beingluminous.com>
vybe
pushed a commit
that referenced
this pull request
Jul 19, 2026
#1296) (#1694) * docs(requirements): agent self-reminders §10.14 (#1296) Requirements-first per Rule of Engagement #1: document the new agent self-reminders capability (durable one-shot deferred self-trigger) before implementing it — AC 1-7, storage fork, scheduler fire home, at-least-once delivery, autonomy hold, and retention. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(db): agent_reminders table — 5-track migration (#1296) Durable one-shot deferred self-trigger storage. Byte-consistent DDL across all five coordinated edits (Invariant #3): - db/schema.py: TABLES entry + two indexes (agent + partial active) - db/migrations.py: _migrate_agent_reminders_table + MIGRATIONS tail - migrations/versions/0028_agent_reminders (off head 0027) - db/tables.py: SQLAlchemy Core Table (byte-matched to DDL) - db/agent_cleanup.py: AGENT_REFS CASCADE (rename cascade + purge + soft-delete filter; source_agent_name left unregistered per the agent_loops precedent) schema-parity, alembic-parity, and cleanup-parity all green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(executions): wire triggered_by="reminder" into all three trigger constants (#1296) A new triggered_by value must hit all three sites or reminder executions silently vanish from the Overview chart, fail to filter, or misclassify their failure alert: - _TRIGGER_BUCKETS + _BUCKET_ORDER (db/schedules.py) — first-class "Reminders" bucket (mirrors the #1150 loops precedent) - _AUTONOMOUS_TRIGGERS (task_execution_service.py) — a reminder fires unwatched, so a FAILED reminder earns an operator alert - _VALID_TRIGGERS (routers/executions.py) — the fleet Executions ?triggered_by= filter accepts it Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(reminders): backend create/list/cancel surface (#1296) Three-layer backend for agent self-reminders (Invariants #1/#2/#8/#14/#18): - models.py: ReminderCreate (fire_at XOR delay_seconds validator) + Reminder/ReminderSummary + env-tunable abuse-bound constants - db/reminders.py: RemindersOperations (SQLAlchemy Core, tenant-scoped by-id ops, CAS cancel, pending/daily counts, retention prune+count) - database.py: facade delegation - services/reminder_service.py: thin create — resolved-window bound, timeout clamp to agent cap (#929 parity), pending + durable daily caps, provenance, relative→absolute, insert - services/idempotency_service.py: derive_reminder_key over RAW input - routers/reminders.py: self-only gate (current_user.agent_name==name) + _reject_connector_principal; create-idempotency (header wins else raw-input key; terminal rows excluded from replay; fail() only pre-insert); tenant-scoped cancel (200/409/404); main.py registration Self-only auth mirrors reports; connector keys stay off the auth-entry allowlist AND are explicitly rejected. Verified against a real migrated SQLite DB; models-centralized + enumeration-uniformity green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(scheduler): arm/fire/reconcile agent self-reminders (#1296) The standalone scheduler (single-instance) owns the reminder fire path — a near-clone of the RETRY-001 one-shot DateTrigger machinery: - models.py: Reminder dataclass (naive-UTC fire_at/firing_at) - database.py: committed CAS methods (claim_reminder_firing pending→ firing, mark_fired/failed, release_to_pending, set_execution) + get_active_reminders (pending∪firing JOIN agent_ownership, filters deleted_at + autonomy_enabled=1) + _row_to_reminder via parse_scheduler_ts - service.py: _schedule_reminder_job (DateTrigger), _execute_reminder (at-least-once: CAS claim → create real execution row (__manual__, triggered_by=reminder) → dispatch via _call_backend_execute_task → timeout=assume-dispatched no-force-FAILED / clean-failure=status- guarded FAILED + bounded retry), _reconcile_reminders (fail-open, arms pending + reclaims stale firing) wired into initialize() (own try after _recover_pending_retries), _sync_schedules() (own try, NOT under the cron try — Codex C5), and reload_schedules() (Codex C6) - config.py: MAX_REMINDER_FIRE_ATTEMPTS (default 3) All 207 existing scheduler tests green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(mcp): set_reminder/list_reminders/cancel_reminder tools (#1296) Third surface (Invariant #13) mirroring loops.ts 1:1: - tools/reminders.ts: createReminderTools → {setReminder, listReminders, cancelReminder}; reuses the inline getClient + resolveAgentName self-scoping (an agent-scoped key defaults to its bound name); Zod carries the abuse bounds (message.max(4000), delay 60..2592000, fire_at ISO string). Idempotency is enforced server-side over the raw input, so a naive retry dedupes without a client-supplied key. - client.ts: setReminder/listReminders/cancelReminder thin request wrappers - server.ts: import + register in the tools array No agent-server mirror (backend-only scheduling primitive). tsc build clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(retention): agent_reminders retention sweep + #1644 guard wiring (#1296) A durable status table needs a retention story (the AGENT_REFS CASCADE only cleans on agent delete; terminal rows on a LIVE agent accumulate). Decision: wire the FULL #1644 blast-radius guard (lightweight, mirrors agent_reports exactly, honors #1638/#1644 discipline): - settings_service.py: agent_reminders_retention_days in OPS_SETTINGS_ DEFAULTS (90, wide/safe), OPS_SETTINGS_DESCRIPTIONS, and RETENTION_OPS_KEYS (surfaced at GET /api/settings/retention, protected from /ops/reset, logged at boot). NOT a community-floor key. - cleanup_service.py: _sweep_agent_reminders_retention — DELETE terminal (fired/cancelled/failed) rows past the window (pending/firing never deleted), chunked, gated via _guard_allows + _after_guarded_prune (default MAX_ROWS_PER_SWEEP floor); CleanupReport.agent_reminders_pruned. retention-guard suite green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(reminders): backend + scheduler coverage (#1296) Backend (tests/unit/test_1296_reminders.py, 33 tests): migration + tables.py live-select accessor guard (the guard schema-parity misses), the three trigger constants (parametrized), ReminderCreate XOR, derive_reminder_key raw-input stability, db round-trip + tenant-scope + cancel CAS states, retention (terminal pruned / pending+firing kept), reminder_service bounds (min/max window, timeout>cap, pending 429, daily 429), and router self-gate ATTACK (sibling 403, connector 403), tenant-scope 404, idempotency edges (dup replay, in-flight 409, cancel-then-recreate-fresh), cancel outcomes, list default-pending. Scheduler (tests/scheduler_tests/test_1296_reminders.py, 17 tests): committed single-fire CAS + multi-connection contention (exactly one wins, Codex C8), _execute_reminder outcomes (claim-loss / success / timeout=assume-dispatched-no-force-FAILED / clean-failure-retry / bounded-failed), _reconcile (arm-once, past-due, stale-firing reclaim, Z-suffix no-raise, missing-table no-op, soft-delete/autonomy-off filtered), reload path (Codex C6). Scheduler suite 224 green; backend reminder+parity+guard sweep 124 green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(architecture): agent self-reminders subsystem (#1296) - New 'Agent Self-Reminders (#1296)' subsystem section (sibling to Sequential Agent Loops): storage fork, scheduler fire home, at-least-once delivery, self-only auth + abuse bounds, autonomy hold + AGENT_REFS cascade + retention - agent_reminders DB schema block (dual-track migration, CASCADE) - router/service catalog entries (reminders.py, reminder_service.py) - MCP tools table row (reminders.ts × 3) - Scheduler Service + Cleanup Service background-service rows updated - API Endpoints section for the 3 reminder endpoints Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(feature-flows): agent-self-reminders flow doc + index (#1296) New feature-flow doc mirroring run-agent-loop.md: the two design forks, MCP/backend/scheduler layers, single-fire + at-least-once delivery semantics, the 5-track migration, retention, and the three trigger constants. Adds a Recent Updates row + a Core Agent Features category row to the index (per the always-add-an-index-row rule). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(config): wire agent-reminder tunables into compose + .env.example (#1296) The six REMINDER_*/MAX_*REMINDER* caps are backend os.getenv() levers with working code defaults, so the feature ships functional — but without compose wiring the .env levers are inert on deploy (the #1056/#1039 packaging class), mirroring how REPORT_RATE_LIMIT (#918) is wired into all three files. Adds the block to .env.example, docker-compose.yml, and docker-compose.prod.yml backend.environment: with defaults matching models.py/routers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: resolve .env.example conflict left in the previous merge commit (keep both #1296 REMINDER_* and #1632 OPERATOR_QUEUE_* blocks) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Eugene Vyborov <eugene@beingluminous.com>
vybe
pushed a commit
that referenced
this pull request
Jul 29, 2026
…cold boundary (#1816) (#1867) * docs(system-agent): base-image adoption semantics for trinity-system (#1816) Rule #1 — documentation before implementation. - architecture.md: #1560 lifecycle-clearing wording ("never evaluated" → "never acted on"), the 3-state split, and the structural AC2 gate; the system_agent_service catalog entry. - feature-flows/internal-system-agent.md: startup diagram gains the drift branch, plus a Base-image adoption section carrying the convergence invariant, the three boundaries, the AC2 gate and the consequences (writable-layer loss, no TRINITY_BACKEND_URL, operator-triggered on the canonical upgrade path). - feature-flows/agent-lifecycle.md: 3-state core + boolean wrapper, system-aware capabilities predicate + recreate override, restart-policy carry-forward. - requirements/infrastructure.md: new 8.5b ADOPT-001..005. - feature-flows.md: Recent Updates row. Refs #1816 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(agents): 3-state base-image check + system-aware capabilities predicate (#1816) T1 — `check_base_image_matches` split into a 3-state `check_base_image_state` core (`match` | `drift` | `unknown`) plus an unchanged boolean wrapper (`state != "drift"`). Every WARNING is kept verbatim and the recreate path still consumes only the boolean, so #1809's behaviour is byte-identical: `unknown` and `match` both fail open to True. The 3-state exists because the staleness alarm this issue adds cannot be built on a boolean whose True means both "the image is current" and "the check could not run" — alarming on that would recreate the #1809 symptom one layer up. T2 — `check_full_capabilities_match` is system-aware. `trinity-system` runs FULL_CAPABILITIES by contract (package installation), not by the fleet default this predicate compares against, so pinning its `trinity.full-capabilities` label alone would, on any install with `agent_full_capabilities=false`, produce a mismatch that can never converge — a recreate on every start, forever. Both route through one shared `is_system_agent_name()` so the checker, the recreate override and the AC2 gate can never disagree. Deliberately a NAME test rather than `db.is_system_agent`: it must be unfailable (a DB error that flips this answer would either recreate the orchestrator or leak full capabilities) and it must not widen the exemption to any `is_system`-flagged row. Verified: tests/unit/test_1809_image_drift_recreate.py (19) plus test_start_agent_skip_inject, test_subscription_auto_switch_no_cred_import, test_inject_assigned_credentials, test_agent_readiness_probe, test_1560_breaker_cleared_on_lifecycle, test_1811_recovery_container_parity, test_base_image_allowlist, test_1484_create_agent_characterization — 102 passed. Refs #1816 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(system-agent): converge creation on the recreate path's contract (#1816) T3 + T-A. `_create_system_agent` left two of the eight config predicates PERMANENTLY false: * `check_agent_auth_token_env_matches` (#1159) — the env dict never wrote TRINITY_AGENT_AUTH_TOKEN; its only writers were crud.py and the two lifecycle recreates. * `check_full_capabilities_match` — the container runs with cap_add=FULL_CAPABILITIES but carried no `trinity.full-capabilities` label, and a missing label reads as 'false' against a fleet default of true. That is not cosmetic. `recreate_container_with_updated_config` resolves the image from the container's own Config.Image *tag*, so every config recreate is also an image adoption — a permanently-false predicate means the first `POST /api/agents/trinity-system/start` after any fresh provision replaces a RUNNING orchestrator and swaps its image mid-operation, which is precisely what AC2 forbids. (Convergent: a recreate writes both values, so only the first start was affected — which is why this survived so long.) The token derive is fail-closed on an unset AGENT_AUTH_SECRET, accepted deliberately: the install now fails to CREATE the system agent rather than creating one the backend can never talk to. ensure_deployed catches → `create_failed`, and main.py's lifespan catch keeps boot alive. Deliberately NOT added: TRINITY_BACKEND_URL. It gates the agent-side heartbeat loop, and authorize_heartbeat accepts only scope='agent' keys — the system agent's is scope='system', so arming it is a permanent 5s 403 loop. Pinned by an AST guard (key-level, not substring — the code documents the omission and a text search would fire on its own rationale). tests/unit/test_1816_system_agent_convergence.py drives `_create_system_agent` for real and builds the fixture from the `environment`/`labels` kwargs it actually passes to containers_run — a hand-built fake carrying both values would assert only that a correct container is correct. Red before this commit on ['agent_auth_token'] + both pins; 11 passed after. Refs #1816 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(agents): restart-policy carry-forward, capability override, AC2 gate (#1816) T4 — three lifecycle fixes the adoption path depends on: * **Restart policy is carried onto the replacement.** `old_host_config` was extracted at the top of `recreate_container_with_updated_config` and then never read, so `unless-stopped` silently vanished from EVERY recreated agent. `trinity-system` is created with it, so one recreate downgraded the platform orchestrator to "stays down after a crash or host reboot". `_provision_folders_and_run_agent_container` takes a keyword-only `restart_policy` and forwards it only when it names a policy, so every pre-#1816 caller is byte-identical. Read null-safely — the key can exist with a null value and `.get` on None would abort the recreate after the old container is already gone. * **`full_capabilities` override.** `None` (every existing caller) resolves to the fleet default for a regular agent and unconditionally True for trinity-system, via the same `is_system_agent_name` the predicate exempts on — writer and checker cannot disagree. * **No `TRINITY_BACKEND_URL` for the system agent.** It gates the agent-side heartbeat loop and `authorize_heartbeat` accepts only scope='agent' keys; the system agent's is scope='system'. #1816 makes this recreate a routine path for it, so arming it would newly create a permanent 5s 403 loop. T5 (AC2) — `start_agent_internal` gains the structural gate: a RUNNING trinity-system is never recreated, and the caller is told `recreate_deferred="system_agent_running"` rather than left to infer it. The gate covers the WHOLE `needs_recreation` block, not just the image predicate: the recreate resolves the image from a tag, so any predicate that fires is also an image adoption — gating one would leave AC2 open through the other eight. Surfaced through `routers/agents.py`'s whitelisted response dict and its audit details (a field added to the internal dict alone dies at the router — #1809's own learning). Two harness stubs updated: a bare `Mock()` auto-creates `is_system_agent_name` returning a truthy Mock, which reads as "every agent is the system agent" and silently suppressed every recreate in test_start_agent_skip_inject. Verified per-file (all green): test_start_agent_skip_inject 9, test_1809_image_drift_recreate 19, test_1816_system_agent_convergence 11, test_agent_readiness_probe 5, test_inject_assigned_credentials 8, test_subscription_auto_switch_no_cred_import 1, plus the 12 other helpers-stubbing files (91 tests). A cross-file sys.modules ordering flake in TestCheckBaseImageMatches (9 tests) reproduces IDENTICALLY on pristine origin/dev @64845458 — pre-existing, not a regression. Refs #1816 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(system-agent): adopt a rebuilt base image at the cold boundary (#1816) The core of the issue. `ensure_deployed` returned `action: none` the instant the container reported `running`, without evaluating a single drift predicate. Combined with `restart_policy: unless-stopped` and a canonical upgrade path (build-base-image.sh → start.sh) that never touches agent containers, that made the platform orchestrator the most-stale agent in every fleet — indefinitely, and silently. T6 — three boundaries, honestly separated: * RUNNING → READ-ONLY. Reports `base_image_state` (`current`|`stale`|`unknown` — an enum only, never image ids, mirroring the /health clone_status contract #1439), WARNs naming the remedy, and never recreates. Source-pinned by slicing the branch between two named anchors, so a refactor that merely relocates a recreate call cannot pass. * STOPPED → delegates to `start_agent_internal` instead of a bare `container_start`. This is the cold boundary where the #1809 image gate fires, and it inherits #1560 clear-before-recreate ordering, the 409/NotFound race hardening, the post-recreate handle re-lookup and every future predicate — rather than forking a second lifecycle for one agent, which is the bug class that produced this issue. * no container → unchanged. Every early return sets both `action` and `message`: main.py's lifespan indexes them directly, so an omission would raise KeyError inside the boot log line. T7 — AC1 on the canonical upgrade path. Since the system agent is RUNNING after every canonical upgrade, the read-only branch is the one that fires, so detection without notification would tell no one. An edge-triggered operator-queue alarm follows the sync_failing/git_bloat idiom (reserved id prefix registered in #1632's anti-spoof guard, priority high, 6h cooldown, emit-failure-safe) and is raised on `stale` ONLY — a fail-open probe must never manufacture an alert, which is exactly why the 3-state split exists. R1 pre-flight: a recreate REMOVES the old container before running the replacement, so a run failure leaves the platform with no orchestrator. When the agent network is missing or the ssh port is bound, the adoption is declined and a plain start runs instead (costing one stale boot — the pre-#1816 status quo — rather than the orchestrator). Fail-open on an unreadable probe; a start failure raises a critical alarm. T8 — /restart delegates (an explicit stop makes it a cold start, so it is the operator's remedy for the alarm) and re-fetches the container for its response, because a recreate replaces the handle. /status gains the 3-state `base_image_state`, reported only while running. /reinitialize deliberately unchanged, pinned by a test. T10 — 49 behavioural cases + the source pins, and the test_1560 create-path pin repaired: `str.index` is first-occurrence, so it stayed green while silently ceasing to pin `_create_system_agent` the moment anything above it grew a `clear_agent_breakers(SYSTEM_AGENT_NAME)` call. Proven by simulation — the old assertion PASSES with the create-path clear removed, the repaired one fails with "the clear must live INSIDE _create_system_agent". Verified: test_1816_system_agent_adoption 49, test_1816_system_agent_convergence 11, test_1560_breaker_cleared_on_lifecycle 16, test_1632_operator_queue_caps 49 — 118 passed. tests/lint_sys_modules.py: no new violations. Refs #1816 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(agents): name the shared helper correctly (is_system_agent_name) (#1816) The docs written ahead of implementation used `is_system_agent()`, which collides conceptually with the existing DB-backed `db.is_system_agent`. Names the real helper and states why it is a name test rather than the DB one. Refs #1816 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(tests): sync the test catalog and the two remaining feature flows (#1816) /update-tests + /sync-feature-flows tail steps. - .claude/agents/test-runner.md: both new unit files catalogued under Operations & Observability, a dated Recent Test Additions block (including the test_1560 pin repair and the two harness-stub fixes), and the totals. - feature-flows/async-docker-operations.md: the new `network_get` wrapper. - feature-flows/operating-room.md: `base-image-stale-` registered in the reserved-prefix enumeration (all 3 sites) and named in the platform-create exemption list. agent-lifecycle.md and internal-system-agent.md were already synced in the docs-first commit; feature-flows.md index is 430 lines. Refs #1816 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor(system-agent): make the state-label map public, merge the duplicate gate (#1816) Self-review polish, no behaviour change: - `_BASE_IMAGE_STATE_LABELS` is consumed by `routers/system_agent.py`, so a leading underscore was wrong — renamed `BASE_IMAGE_STATE_LABELS`. - `get_system_agent_status` had two consecutive `if status == "running":` blocks; folded the health fetch into the first. It keeps its own try/except, so `base_image_state` is still set when the agent is unreachable. Verified: test_1816_system_agent_adoption + test_1816_system_agent_convergence — 60 passed. Refs #1816 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor(system-agent): pass the container into the adoption pre-flight (#1816) The stopped branch already holds the handle; re-fetching it inside `_preflight_ok_for_delegated_start` was a wasted Docker round-trip on the boot path and a needless TOCTOU window. Still null-safe throughout, so a partially populated handle degrades to "no port to check" rather than raising. Verified: 60 passed (adoption + convergence). Refs #1816 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(system-agent): make the #1816 suites CWD-independent (#1816) Caught by verify-local, not by any local run: `_create_system_agent` resolves its template through a CWD-RELATIVE fallback (`./config/agent-templates`, used whenever `/agent-configs/templates` is absent, i.e. off-container), and verify-local runs pytest from `tests/`. From there creation died on a missing template BEFORE reaching the token derive, so `test_creation_without_agent_auth_secret_fails_closed_without_blocking_boot` was asserting the wrong failure — green from the repo root, red from `tests/`. Both suites now pin the CWD to the repo root with the reason stated, so they hold wherever pytest is invoked from. Verified from BOTH cwds: 60 passed each. A test bug, not a code bug — the behaviour under test is unchanged. Refs #1816 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(system-agent): state the staleness alarm's per-worker reach (#1816) `ensure_deployed` runs once per worker lifespan and the cooldown cursor is per-process, so a stale boot files one operator-queue item per worker (`--workers 2` ⇒ 2). Deliberate rather than overlooked — a cross-worker cursor puts Redis or a DB read on the boot path for an advisory alarm, and the un-guessable timestamped id (what stops an agent pre-creating and silencing it, per #1632) is inherently undedupable by `on_conflict_do_nothing`. Said plainly in both the code and the flow doc rather than left for a reviewer to discover. Refs #1816 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(system-agent): fence the delegated start, gate the operator remedy (#1816) Review + CSO follow-ups on the base-image adoption work. - recreate_missing_container refuses trinity-system (409). start_agent_internal falls through to it when the container lookup returns None, and #1816 newly reaches that from the boot path and /restart — ensure_deployed runs in every uvicorn worker with no leader lock, so a concurrent recreate can null the lookup mid-flight. That path reconstructs a REGULAR agent: it deactivates the system-scoped MCP key and mints an agent-scoped one (plaintext unrecoverable, so the orchestrator irreversibly loses its permission bypass), drops trinity.is-system, the /template bind and unless-stopped, and arms the scope-403 TRINITY_BACKEND_URL. ensure_deployed's create branch rebuilds it correctly on the next boot; the race itself is #1817. - /restart and /reinitialize are human-only. assert_admin rejects connector principals but not agent ones, and get_current_user hands an agent-scoped key its owner's role — so on a default admin-owned install any non-ephemeral agent's TRINITY_MCP_API_KEY passed it. Tolerable while /restart was a stop+start; not once it replaces the container. trinity-ops-agent#232 precedent; no-op for JWT / user-scoped / system-scoped callers. - Sanitize the start-failure alarm's interpolated exception string. It lands in operator_queue.question — durable, operator-visible state — from a path that builds env dicts holding OAuth tokens and PATs. The staleness alarm was built to carry no identifiers; this one wasn't. - Bound that alarm across processes via a bucketed id. A per-process cursor cannot bound a failure whose symptom is a fresh process, and retention never deletes a pending row. A bucket (not a fixed id) so Clear All → cancelled can't wedge it shut through on_conflict_do_nothing; guessability is fine because the prefix is in _RESERVED_ID_PREFIXES. - Cooldown cursor uses time.monotonic() — datetime.utcnow() is deprecated and a wall-clock step could skip or extend the gate. - State the real boot cost in the comment (~20-90s of blocked lifespan, not one timeout) and name the multi-worker exposure. 9 tests; 5151 unit tests green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(system-agent): sync architecture.md with the review-pass fences (#1816) The last commit added two fences but only reached requirements + the feature flow. architecture.md is the current-design doc and carries the invariant, so both belong here too. - system_agent_service entry: recreate_missing_container refuses trinity-system (409, ADOPT-006). Worth stating where the service is described, because the reader's natural assumption is that the generic recovery rebuild is a valid way to bring the orchestrator back — it is not, and the downgrade it causes (system-scoped MCP key deactivated for an agent-scoped one) is irreversible. - Invariant #8 gains "Role ≠ human": assert_admin/require_admin answer what role, never is-this-a-human, because get_current_user resolves an agent-scoped MCP key to its owner carrying the owner's role. Generalized from the two known instances (#1644 retention ack, #1816 /restart + /reinitialize) into the rule that produced them — an endpoint whose blast radius is operator-scale needs reject_agent_principal in ADDITION to the role gate, and the trigger to revisit an existing gate is a change in what the endpoint DOES. Escalating a handler's destructiveness silently re-prices every principal that could already reach it. Refs #1816 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(system-agent): pin the real modules against sibling sys.modules leaks (#1816) The adoption suite passed alone and in the full alphabetical run, but 21 of its 67 tests failed when collected alongside tests/unit/test_start_agent_skip_inject.py — i.e. it was green by luck of import order, and CI randomizes. Five sibling unit files replace services.agent_service / .helpers with Mock objects in sys.modules at COLLECTION time (the grandfathered #762 class). This file resolves the real modules lazily, inside fixtures — after the contamination. The docstring already noted the file is not a contaminator; not being one is not the same as being immune to one. The failure mode is silent rather than loud, which is the reason to fix it rather than order the files: a leaked helpers Mock makes is_system_agent_name return False for everything, so the AC2 gate never fires and the recreate it exists to suppress happens — while every assertion still looks meaningful. check_base_image_state degrades the same way (a Mock can never return "unknown", so the never-alarm-on-unknown property becomes untestable). Fix: import the real modules at collection time (this file sorts before all five) and pin them per-test with monkeypatch.setitem, which self-restores — so the siblings' own harnesses, which hold direct module references rather than sys.modules lookups, are untouched. No new lint_sys_modules violations (monkeypatch.setitem is the sanctioned form). Reproduction now green: adoption + skip_inject 21 failed -> 0; the original 5-file set 21 failed -> 108 passed. Refs #1816 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Eugene Vyborov <eugene@beingluminous.com>
dolho
added a commit
that referenced
this pull request
Jul 30, 2026
…hange (ent#293) Review of #1890 found the PR body's "no MCP tools hit admin endpoints" claim inaccurate: six do. Four of those 403s are the grant-vs-use line working as intended and stay — get_credential_encryption_key, get_agent_ssh_access, register_subscription, delete_subscription all hand out or revoke a platform credential. Two were reads, not grants, and left a half-working workflow, which is worse than a cleanly closed door: - trigger_health_check: an ops agent could READ health (GET /status, GET /agents/{name}, both non-admin) but not REFRESH it. The require_admin was redundant anyway — AuthorizedAgentByName already restricts the endpoint to callers who own or are shared the agent. Probing an agent you can already see is a use of a capability, not a grant of one. - list_subscriptions: assign_subscription / clear_agent_subscription / get_agent_auth all kept working under owner gates, so an agent could ASSIGN a subscription it could no longer ENUMERATE. list_subscriptions is SCOPED rather than un-gated. Un-gating was the first attempt and it was wrong: the payload carries owner_email and the full agent list of every subscription, so an ungated read hands any role=user account a fleet-wide owner-email and agent-name enumeration oracle — the disclosure class Invariant #8 exists to prevent, reintroduced by a fix for a different disclosure. Admin keeps the fleet view; everyone else gets owner_id-scoped, via a filter already in the accessor's contract. An agent key is excluded from the fleet view by the agent_name term even though it carries the owner's admin role, which is the whole point of ent#293. Also corrects a stale "Admin-only" docstring on get_subscription_usage, whose gate has always been get_current_user. Tests pin all three resolutions plus the four intended 403s, so the next change to the gate cannot silently re-break them. The access-gate assertion reads the SOURCE annotation rather than the resolved dependency: sibling monitoring tests override that dependency with a lambda, so a runtime check passes standalone and fails in a full run purely on collection order. Related to Abilityai/trinity-enterprise#293 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
vybe
pushed a commit
that referenced
this pull request
Jul 31, 2026
…gration, API, UI (ent#264) Default-ON toggle for the in-progress indicator, per-agent == per-binding: - Dual-track migration (Invariant #3 / Rule #9), all six touch points: SQLite `telegram_progress_indicator` in db/migrations.py + Alembic 0031_telegram_progress_indicator (down_revision 0030; renumber-at-rebase rule vs ent#265 applies to whichever PR merges second) + schema.py + tables.py DDL + _BINDING_COLUMNS + _row_to_binding. No backfill UPDATE: ADD COLUMN ... DEFAULT 1 populates existing rows (default ON is the AC). - Read predicate evaluated in Python, never SQL (`NULL != 0` is NULL): enabled ⇔ `v is None or v != 0` — only an explicit 0 disables. - db: set_progress_indicator_enabled + set_telegram_progress_indicator facade. - API: GET /api/agents/{name}/telegram surfaces progress_indicator_enabled (pinned in the hand-built response, #1809 lesson — configure PUT too) and is access-hardened get_current_user → AuthorizedAgentByName (the response carries webhook_url, which embeds the webhook secret — previously readable by ANY authenticated user; uniform-404 accessor, Invariant #8). New PUT /api/agents/{name}/telegram/progress-indicator — OwnedAgentByName + reject_agent_principal (behavior toggles are human-only, ent#223 lesson), 404 when no binding; dedicated route so toggling never re-sends the token. - models.py: TelegramProgressIndicatorRequest + response field (Invariant #14). - UI: TelegramChannelPanel.vue switch (SlackChannelPanel allow_proactive precedent), optimistic flip with revert-on-error, dark-mode aware. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Merged
3 tasks
obasilakis
pushed a commit
that referenced
this pull request
Jul 31, 2026
…ifecycle path (#1860) (#1912) * fix: route fleet restart through the canonical lifecycle path so agents adopt rebuilt base images (#1860) POST /api/ops/fleet/restart stopped/started agents with raw Docker calls, bypassing start_agent_internal — no config-drift predicate ran and a rebuilt trinity-agent-base was never adopted on "Restart All" (#1809's cold-start gate never fired). - lifecycle.restart_agent_internal(): the canonical stop→cold-start helper (explicit stop is load-bearing for the #1809 image predicate; future home of #1817's per-agent start lock) - restart_fleet routes through it; per-agent recreated/recreate_reason surfaced via explicit allowlist copy, summary.recreated count - skips ephemeral ghosts (config predicates aren't ephemeral-gated — a recreate would destroy a volume-less ghost workspace, ent#69) - reject_agent_principal beside assert_admin (the endpoint now replaces containers — Invariant #8 escalation rule, #1816 precedent) - single-flight Redis SETNX lock ops:fleet_restart (409 on contention, own-lease refresh, compare-and-delete release, fail-open) — guards the client-timeout→retry overlap (#799/#1817 wedge class) - partial-safe fleet_restart audit entry with a per-agent recreate map (restores the entry dropped in 0ec3a7f); sync cleanup ordered before the awaited audit so a shutdown CancelledError can't leave the lock held - actionable containerless-recovery errors (#1559), context-stats cache invalidation, 16 mocked unit tests, flow/architecture docs, learnings, CSO diff report (PASS) Fixes #1860 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: never flow a raw exception message into fleet-restart results (CodeQL py/stack-trace-exposure) Per-agent failure rows now carry HTTPException .detail (platform-authored) or the exception class name only; the full message + traceback go to the backend log (exc_info). The #1559 containerless recovery hint is preserved. Tests strengthened into leak regression guards (raw message asserted absent); flow-doc line refs re-verified. Refs #1860 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
4 tasks
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.
Security Fix: Safe Tar Extraction
Description
This PR implements secure tar archive extraction for the local agent deployment service (
deploy.py). It addresses the Path Traversal vulnerability identified in the security audit.Previously, the code only checked for
..and absolute paths in member names, which was insufficient to prevent Zip Slip attacks via symlinks or hardlinks.Changes
_validate_tar_member()helper to validate every archive member before extraction._is_path_within()usingpathlib.Path.resolve()to securely check path containment...traversal.tests/test_archive_security.pycovering all edge cases.Verification
pytest tests/test_archive_security.py/etc/passwd) confirms it is rejected withINVALID_ARCHIVE.