Skip to content
Merged
8 changes: 4 additions & 4 deletions docs/memory/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,10 @@

**OpenTelemetry tracing** (RELIABILITY-002): auto-instrumentation for FastAPI/httpx/Redis; `traceparent` propagated through inter-agent calls; OTLP/gRPC export to `trinity-otel-collector:4317`; `OTEL_ENABLED=1`, `OTEL_SAMPLE_RATE` (default 10%).

**Routers (`routers/`)** — 53 router modules:
**Routers (`routers/`)** — 63 router modules:

*Core Agent:*
- `agents.py` - Core CRUD, start/stop, logs, stats, queue, activities, terminal (642 lines)
- `agents.py` - Core CRUD, start/stop, logs, stats, queue, activities, terminal (1054 lines)
- `agent_config.py` - Per-agent settings: autonomy, read-only, resources, capabilities, capacity, timeout, api-key
- `agent_files.py` - Files, info, playbooks, permissions, metrics, shared folders, file-sharing toggle + list/revoke (FILES-001)
- `agent_data.py` - Runtime-data export/import (`data_paths`) over the durable home volume (#1169)
Expand Down Expand Up @@ -139,7 +139,7 @@
- `system_agent.py` - System agent management
- `sessions.py` - Session tab endpoints — see [Session Tab](#session-tab)

**Services (`services/`)** — 37 service modules:
**Services (`services/`)** — 66 service modules:

*Core:*
- `docker_service.py` - Docker container management (single point of Docker interaction, Invariant #11)
Expand Down Expand Up @@ -807,7 +807,7 @@ These are structural patterns that must be preserved. Breaking them causes casca

13. **MCP Server = Third Surface in Sync** — The MCP server (`src/mcp-server/src/tools/*.ts`) is a TypeScript proxy over the backend API. When adding a backend endpoint for external access, the MCP tool module needs updating too. Three surfaces must stay in sync: backend router, agent server (if internal), MCP tool (if external).

14. **Pydantic Models Centralized in `models.py`** — Request/response models live in `models.py`, not scattered across routers. Keeps the API contract in one place.
14. **Pydantic Models Centralized in `models.py`** — Request/response models live in `models.py`, not scattered across routers (#654). Keeps the API contract in one place. **Scope:** this invariant governs **router** models — a `class X(BaseModel)` must not be defined under `routers/` (enforced by the static guard `tests/unit/test_models_centralized.py`). Two model homes are **intentionally separate** and out of scope: `db_models.py` (DB-row / persistence models — a distinct layer) and `adapters/base.py` (the ChannelAdapter ABC's `NormalizedMessage`/`ChannelResponse`). One documented exception, allowlisted in the guard: `routers/canary.py::RunCycleRequest` evaluates `INVARIANTS` (from the `canary` library) in a `Field(description=…)` at class-definition time, and the `canary` library imports `TaskExecutionStatus` back from `models` — relocating it would force `models.py` to `from canary import …`, inverting the dependency direction of a module meant to be a low-level leaf everything imports *from*.

15. **API URL Nesting Convention** — Agent-scoped resources nest under `/api/agents/{name}/...`. Platform-wide resources get top-level prefixes (`/api/executions`, `/api/operator-queue`).

Expand Down
1 change: 1 addition & 0 deletions docs/memory/feature-flows.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
| Date | ID | Feature | Flow |
|------|-----|---------|------|
| 2026-06-26 | #1131 | fix(deploy): probe the in-container `docker.sock` GID so the non-root (UID 1000) backend reaches Docker on **macOS Docker Desktop / Colima / Rancher / rootless**. The #874 non-root rollout wrongly assumed Docker Desktop *ignores* compose `group_add`, so `start.sh` returned early on non-Linux and left `DOCKER_GID` at the Debian default `999`; those runtimes present the socket **root-group-owned (GID 0)**, so UID 1000 was denied — and `list_all_agents_fast()` swallowed the `PermissionError`, so `GET /api/agents` returned `[]` and the Agents page silently showed **"No agents"**. Fix: `ensure_docker_gid()` now **probes the GID a throwaway container sees on the mounted socket** as the PRIMARY path (Docker Desktop/Colima/rootless → `0`, native Linux → host docker GID), keeping `getent group docker` only as the offline Linux fallback; an explicit `DOCKER_GID=<n>` in `.env` (incl. `0`) always wins. The swallowed-exception `print()` becomes a **throttled `logger.warning()`** (≤1/60 s on this hot path) so a denied socket is observable in Vector. New hermetic `verify-docker-gid-detection` CI job (shims `docker`/`getent`/`uname`, extracts the real `start.sh` functions, ~1 s, no daemon) — the only guard for the probe branch, since `verify-non-root` runs where the socket is already `docker`-group-owned. Non-root invariant #17 preserved (UID stays 1000; only socket-group membership changes). | [docker-socket-gid-detection.md](feature-flows/docker-socket-gid-detection.md) |
| 2026-06-23 | #654 | refactor(models): centralize router Pydantic models in `models.py` (**Invariant #14**). ~97 request/response models that had been defined inline across `routers/*.py` were moved into the single `src/backend/models.py` and imported back into their routers — the API contract now lives in one place. **Pure refactor, no behavioral change:** every model's fields/validators/`default_factory` are unchanged and the generated **OpenAPI schema is byte-identical** (proven, not assumed). `models.py` is kept a **flat module** (not a `models/` package) on purpose; `db_models.py` (DB-row/persistence layer) and `adapters/base.py` (`NormalizedMessage`/`ChannelResponse` for the ChannelAdapter ABC) remain **intentional separate homes** and are out of scope. One documented allowlist exception: `routers/canary.py::RunCycleRequest` stays in its router because it evaluates the `canary` library's `INVARIANTS` in a `Field(description=…)` at class-definition time and `canary` imports `TaskExecutionStatus` back from `models` — relocating it would invert that dependency direction. Guarded by a static test `tests/unit/test_models_centralized.py` (fails any new `class X(BaseModel)` defined under `routers/`) plus `test_router_model_validators.py` (validator/default-factory behavioral parity). **Note for flow docs:** illustrative `class XRequest(BaseModel)` snippets in individual flows stay field-accurate — only the physical definition home moved (router → `models.py`); the INV-7/INV-8 router/service-layer cleanups were split out to #1309/#1310. | [architecture.md → Invariant #14](architecture.md) |
| 2026-06-22 | trinity-enterprise#38, #82 | feat(setup): first-run operator intake + admin email login. Optional **email + company** at first-run setup with an **unchecked-by-default** opt-in ("occasionally receive important security & product updates"); on consent → one **fire-and-forget, at-most-once** POST to a new `/v1/operator-intake` endpoint on #1116's Cloudflare intake app (`intake.abilityai.dev`, already CSP-allowed — **one added endpoint**, Worker out-of-repo). The same email **binds as the admin's sign-in identity** (log in with email + password) — **no verification email is sent** (a fresh install has no Resend key; bound, not code-verified); the code-based email second factor stays **Phase 2** on the existing `mfa_gate` seam (#5/#388), untouched here. Backend: `services/operator_intake_service.py` (DO_NOT_TRACK/`OPERATOR_INTAKE_ENABLED` off-switch, `operator_intake_submitted` marker claimed before the POST, owns `installation_id` — the #758 seed, never raises/logs the email); `routers/setup.py` captures profile + binds email (validates shape before any write, schedules intake via `BackgroundTasks` so it never delays setup); `dependencies.authenticate_user` resolves admin by username **OR** registered email (password-hash guard keeps email-code-only users off the password path); `PUT /api/users/me/email` (own-account, 409 on collision) for the existing-admin transition. Frontend: `SetupPassword.vue` fields + consent, `Login.vue` "Username or email", `Settings.vue` → General "Admin sign-in email" card. `OPERATOR_INTAKE_ENABLED`/`OPERATOR_INTAKE_URL` in `.env.example`. 16 unit tests. | [first-time-setup.md](feature-flows/first-time-setup.md) |
| 2026-06-22 | #1084 | feat(reliability): **effect-scoped idempotency** for outbound side effects — extends RELIABILITY-006 (#525) past the trigger boundary to the SINK, so a re-delivered turn (the at-least-once semantics pull-mode / work-stealing, Epic #1045/#1081, will introduce) does NOT re-send a message, re-charge a payment, re-place a call, or re-mint a share. The same `services/idempotency_service.py` adds `effect_guard(effect_type, identifying_args, *, execution_id, agent_name, dedup_label, payment_request_id)` over the existing `begin`/`complete`/`fail` (**no schema/migration change**, reuses the 24h TTL which already exceeds the lease window). Scopes: `effect:{execution_id}` (messages/voip/share_file, after `resolve_and_validate_execution` confirms ownership — generalizes MEM-001), `payment:{agent_request_id}` (Nevermined native token). Key hashes **resolved, immutable** identity only (recipient/channel/account/filename+content) — **never the LLM-generated body** (would defeat dedup); `dedup_label` allows an intentional repeat. `in_flight ≠ completed`: completed → replay sanitized snapshot; in_flight → `EffectInProgressError` → router 409 (never a silent skip-and-succeed, codex #6). Wired sinks: `proactive_message_service.send_message`, `voip_service.place_outbound_call`, `agent_shared_files_service.create_share`, `nevermined_payment_service.settle_payment_once` (preserves the terminal-turn no-settle guard; native token stays the real guarantee). MCP `execution_id`+`dedup_label` params on `messages.ts`/`voip.ts`/`files.ts` → body in `client.ts` (Inv #13); agent supplies execution_id from its 'Execution Context' block, **fail-open when absent** (safe — pull-mode re-delivery is OFF). git push is idempotent-by-construction (no key). **Pull-mode default-ON for side-effect agents is GATED**: requires trusted runtime injection of execution_id + fail-closed-when-absent first — a BLOCKING prerequisite on Epic #1045/#1081 (comment on `dispatch_async_eligible`). TDD: 55 backend unit tests + `messages.test.ts`. | [effect-idempotency.md](feature-flows/effect-idempotency.md) |
| 2026-06-21 | #1159 | fix(security): authenticate the in-container agent server on the shared agent network — the agent server (`:8000`, FastAPI) historically had **zero auth** on the shared `trinity-agent-network`, so any agent could call a sibling's chat/files/credentials (a now-removed unauthenticated `/ws/chat` route even ran `runtime.execute` — arbitrary Claude). Every backend→agent call now carries a per-agent `X-Trinity-Agent-Token = HMAC-SHA256(AGENT_AUTH_SECRET, "trinity-agent-auth:v1:"+name)` — **derived, not stored** (no DB column; the master lives only in the backend env, so a compromised agent holds its own token but cannot compute a sibling's). New `services/agent_auth.py` helpers (`derive_agent_token`/`build_agent_auth_headers`/`merge_auth_headers`/`agent_httpx_client`), fail-closed (raises on empty `AGENT_AUTH_SECRET`); `start.sh` auto-generates the hex32 master and both compose files forward it. Enforced by a **pure-ASGI** middleware (`agent_server/middleware/auth.py`, `hmac.compare_digest`) on **all** HTTP **and** WebSocket routes (scope-complete, never buffers SSE), exempting only exact `/health` + `OPTIONS`; WS rejects with close 1008 before `accept()`. Grace path: empty `TRINITY_AGENT_AUTH_TOKEN` → allow (old-image rollout); `check_agent_auth_token_env_matches` forces a one-pass recreate to re-inject a missing/stale (renamed-agent) token. Dead `/ws/chat` route + CORS `*` removed (agent server is internal-only). ~10 backend routers/services migrated through the helpers; static guard `test_agent_auth_header_guard.py` fails any new raw `agent-{name}:8000` caller. | [agent-server-authentication.md](feature-flows/agent-server-authentication.md) |
Expand Down
2 changes: 1 addition & 1 deletion docs/memory/feature-flows/audit-trail.md
Original file line number Diff line number Diff line change
Expand Up @@ -542,4 +542,4 @@ Run e2e: `cd src/frontend && npm run test:e2e -- audit-dashboard.spec`
- **Invariant #3** (Schema in schema.py, migrations in migrations.py): both updated together
- **Invariant #4** (Router registration order): `/stats` before `/{event_id}` in router declaration
- **Invariant #8** (Auth pattern): every endpoint uses `Depends(require_admin)`
- **Invariant #15** (Pydantic models centralized): all response models in `routers/audit_log.py` (consistent with other routers that own their response shapes)
- **Invariant #14** (Pydantic models centralized): all audit response models live in `src/backend/models.py` (`AuditLogEntry`/`AuditLogListResponse`/`AuditLogStatsResponse` at `models.py:947-982`), centralized out of `routers/audit_log.py` per #654
2 changes: 1 addition & 1 deletion docs/memory/feature-flows/business-validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ class BusinessStatus(str, Enum):
|-------|------|---------|
| Backend | `src/backend/services/validation_service.py` | Core validation logic |
| Backend | `src/backend/routers/internal.py` | `/validate-execution` endpoint |
| Backend | `src/backend/routers/schedules.py` | Schedule/execution response models |
| Backend | `src/backend/models.py` | Schedule/execution response models (centralized per Invariant #14, #654) |
| Backend | `src/backend/db/schedules.py` | DB operations for validation |
| Backend | `src/backend/models.py` | BusinessStatus enum |
| Scheduler | `src/scheduler/service.py` | Triggers validation after execution |
Expand Down
2 changes: 1 addition & 1 deletion docs/memory/feature-flows/scheduling.md
Original file line number Diff line number Diff line change
Expand Up @@ -1364,7 +1364,7 @@ class Schedule(BaseModel):
model: Optional[str] = None # Model override (MODEL-001). None = agent default
```

**ScheduleUpdateRequest** (`src/backend/routers/schedules.py:76-86`):
**ScheduleUpdateRequest** (`src/backend/models.py:1574` — centralized per Invariant #14, #654):
```python
class ScheduleUpdateRequest(BaseModel):
# ... existing fields ...
Expand Down
72 changes: 72 additions & 0 deletions docs/security-reports/cso-diff-2026-06-23-inv14-models.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# CSO Security Audit

**Mode**: diff
**Scope**: `AndriiPasternak31/autoplan-issue-654` vs `origin/dev` (#654 INV-14 model centralization, PR #1308)
**Date**: 2026-06-23
**Diff size**: 37 files, +1548 / −1092

## Summary

| Category | CRITICAL | HIGH | MEDIUM | LOW |
|----------|----------|------|--------|-----|
| Secrets | 0 | 0 | 0 | 0 |
| Dependencies | 0 | 0 | 0 | 0 |
| Auth Boundaries | 0 | 0 | 0 | 0 |
| Injection | 0 | 0 | 0 | 0 |
| Platform Patterns | 0 | 0 | 0 | 0 |
| Configuration | 0 | 0 | 0 | 0 |

## Nature of the change

Pure refactor enforcing Architectural Invariant #14: ~97 Pydantic request/response
models are relocated out of 32 router modules into the centralized `src/backend/models.py`.
No business logic, route handler, auth dependency, or persistence code is added.
A new AST static guard (`tests/unit/test_models_centralized.py`) forbids `class X(BaseModel)`
under `routers/` going forward, with a single documented allowlist entry
(`canary.py::RunCycleRequest`, justified by a class-definition-time dependency inversion).

## Verification performed

1. **Secrets** — no hardcoded credentials, tokens, or API keys introduced
(pattern scan on added lines clean). No `.env`/manifest files in scope.
2. **Dependencies** — no `requirements*.txt` / `package.json` / `pyproject` changes;
no new packages, no version bumps → no new CVE surface.
3. **Auth boundaries** — **zero** `Depends(...)`, `require_admin`, `require_role`,
`AuthorizedAgent`, `OwnedAgentByName`, or `get_current_user` lines added or removed
across all 32 router diffs. Route handler signatures (and their guards) are untouched.
4. **Injection / boundary validation** — every moved `@field_validator` was confirmed
**byte-identical** in `models.py` (fan-out task-id regex `^[a-zA-Z0-9_-]{1,64}$`,
task/concurrency/timeout bounds, `policy` whitelist, loop `stop_signal` normalization).
The constants backing those validators moved with **identical values**
(`TASK_ID_RE`, `MAX_TASKS=50`, `MAX_CONCURRENCY=10`, `MAX_RUNS_LIMIT=100`,
`MAX_MESSAGE_LEN=100_000`, `MAX_STOP_SIGNAL_LEN=200`, `MAX_DELAY_SECONDS=3600`,
`MAX_TIMEOUT_PER_RUN=7200`, `CONTEXT_MAX_CHARS=4000`) and are no longer
duplicated in the routers (no divergent definition can drift).
5. **Security-relevant defaults** — permission/access fields (`require_email`,
`open_access`, `read_only`, `group_auth_mode="none"`, `validation_enabled=False`,
`welcome_enabled=False`, `allow_proactive`) appear identically on both `+`/`-` sides
of the diff — moves, not flips. No default weakened.
6. **Import integrity** — each router now imports its models from the centralized
`models` module; the prompt-injection-surface cap `CONTEXT_MAX_CHARS` is correctly
imported by `webhooks.py` rather than dropped.

The PR additionally carries a byte-identical OpenAPI proof (covers field constraints,
required-ness, and defaults); this audit independently verified the custom-validator
logic that OpenAPI does not fully capture.

## Findings

#### CRITICAL
None

#### HIGH
None

#### MEDIUM / LOW
None

## Recommendation

**CLEAR** — no security impact. The change is a mechanical, behavior-preserving
relocation of model definitions; input validation and authorization surfaces are
provably unchanged, and a new static guard prevents regression of the invariant.
Loading
Loading