diff --git a/tests/registry.json b/tests/registry.json index db92322ee..317368ae1 100644 --- a/tests/registry.json +++ b/tests/registry.json @@ -9,7 +9,7 @@ "a2a", "integration" ], - "description": "A2A inbound server (ent#157): card honesty (protocolVersion 0.3.0, JSON-RPC url); public well-known gating (uniform 404 non-exposed/nonexistent, stopped-agent label fallback); JSON-RPC dispatch + error codes (-32700/-32600/-32601/-32602/-32001); message/send bridge to execute_task(triggered_by=a2a); message/stream SSE (working\u2192final completed); tasks/get state mapping (success/failed/cancelled/running/queued/unknown) + tasks/cancel; multipart concat + empty-text invalid; messageId idempotency (completed replay + in-flight retryable); a2a_gate allow-list allow/deny/fail-open; unauthenticated POST \u2192 401. TestClient, no Docker." + "description": "A2A inbound server (ent#157): card honesty (protocolVersion 0.3.0, JSON-RPC url); public well-known gating (uniform 404 non-exposed/nonexistent, stopped-agent label fallback); JSON-RPC dispatch + error codes (-32700/-32600/-32601/-32602/-32001); message/send bridge to execute_task(triggered_by=a2a); message/stream SSE (working→final completed); tasks/get state mapping (success/failed/cancelled/running/queued/unknown) + tasks/cancel; multipart concat + empty-text invalid; messageId idempotency (completed replay + in-flight retryable); a2a_gate allow-list allow/deny/fail-open; unauthenticated POST → 401. TestClient, no Docker." }, { "file": "unit/test_a2a_exposure_mixin.py", @@ -20,7 +20,7 @@ "a2a", "database" ], - "description": "A2AExposureMixin (ent#157) OSS a2a_exposed primitive: default-false (no row / unset column), set/get round-trip, per-agent isolation, missing-agent set\u2192False, deleted_at guard (soft-deleted agent never flipped/read exposed), get_a2a_exposed_agents live-only listing. sqlite3 + SQLAlchemy engine at one temp file." + "description": "A2AExposureMixin (ent#157) OSS a2a_exposed primitive: default-false (no row / unset column), set/get round-trip, per-agent isolation, missing-agent set→False, deleted_at guard (soft-deleted agent never flipped/read exposed), get_a2a_exposed_agents live-only listing. sqlite3 + SQLAlchemy engine at one temp file." }, { "file": "unit/test_180_a2a_exposed_skills.py", @@ -31,7 +31,7 @@ "a2a", "unit" ], - "description": "A2A exposed-skills filter (ent#180): the open-core seam that narrows a card's skills[] to what an agent may advertise. OSS-unchanged by construction (no provider \u2192 identity, same list object); None = no opinion = advertise all (the unconfigured default, so an exposed agent's card is byte-identical across upgrade) vs [] = explicit advertise-nothing; stale/unknown stored ids are inert (the selection only subtracts, the template stays the source of truth); fail-open on provider error AND on a malformed return (a str would otherwise iterate to chars and silently empty the card \u2014 fail-closed, invisible); both card surfaces filter via one router helper (structural guard against a route rebuilding an unfiltered card). Disclosure control only \u2014 message/send dispatches free-form text, so this never narrows what a caller may ask for." + "description": "A2A exposed-skills filter (ent#180): the open-core seam that narrows a card's skills[] to what an agent may advertise. OSS-unchanged by construction (no provider → identity, same list object); None = no opinion = advertise all (the unconfigured default, so an exposed agent's card is byte-identical across upgrade) vs [] = explicit advertise-nothing; stale/unknown stored ids are inert (the selection only subtracts, the template stays the source of truth); fail-open on provider error AND on a malformed return (a str would otherwise iterate to chars and silently empty the card — fail-closed, invisible); both card surfaces filter via one router helper (structural guard against a route rebuilding an unfiltered card). Disclosure control only — message/send dispatches free-form text, so this never narrows what a caller may ask for." }, { "file": "unit/test_1896_integration_nightly_workflow.py", @@ -78,7 +78,7 @@ "github", "security" ], - "description": "GitHub import intents (ent#15): stage_github_snapshot with an object-patched async _run_git fake that plays the clone into the staging dir \u2014 success (head SHA capture, .git strip, escaping-symlink prune with in-tree symlink survival, regular-file count, PAT only on the clone call), COPY_SOURCE_UNREADABLE 400 (auth/not-found/remote-branch shapes) + staging cleanup, COPY_CLONE_FAILED 502 transient, COPY_SOURCE_EMPTY 400, --branch flag presence/absence; crud._resolve_template intake gates pre-side-effect (INTENT_REQUIRES_GITHUB_TEMPLATE, FORK_PARAMS_REQUIRED, INTENT_FORK_BLOCK_CONFLICT), intent-less local:default resolving gate-free, and copy-branch wiring (snapshot staged, github_repo/git_instance_id stay None, tokenless source-mode gate never fires \u2014 source_mode parametrized None/False/True); create_agent_endpoint idempotency \u2014 scope folds the caller (two principals, same key \u2192 different scopes), replay returns snapshot + X-Idempotent-Replay without re-creating, in-flight \u2192 409 CREATE_IN_FLIGHT. Real modules owned per test (2026-07-12 learning); no Docker/DB." + "description": "GitHub import intents (ent#15): stage_github_snapshot with an object-patched async _run_git fake that plays the clone into the staging dir — success (head SHA capture, .git strip, escaping-symlink prune with in-tree symlink survival, regular-file count, PAT only on the clone call), COPY_SOURCE_UNREADABLE 400 (auth/not-found/remote-branch shapes) + staging cleanup, COPY_CLONE_FAILED 502 transient, COPY_SOURCE_EMPTY 400, --branch flag presence/absence; crud._resolve_template intake gates pre-side-effect (INTENT_REQUIRES_GITHUB_TEMPLATE, FORK_PARAMS_REQUIRED, INTENT_FORK_BLOCK_CONFLICT), intent-less local:default resolving gate-free, and copy-branch wiring (snapshot staged, github_repo/git_instance_id stay None, tokenless source-mode gate never fires — source_mode parametrized None/False/True); create_agent_endpoint idempotency — scope folds the caller (two principals, same key → different scopes), replay returns snapshot + X-Idempotent-Replay without re-creating, in-flight → 409 CREATE_IN_FLIGHT. Real modules owned per test (2026-07-12 learning); no Docker/DB." }, { "file": "unit/test_1870_completed_turn_recovery.py", @@ -140,7 +140,7 @@ "brain-orb", "feature-flags" ], - "description": "Admin-configurable Brain Orb flags (trinity-enterprise#85): _resolve_bool_flag order (stored wins both directions, env opt-in, default OFF, junk-value fail-safe, fail-open on DB error), GET/PUT /api/settings/brain-orb (per-flag source, partial update, clear revert-to-env, set+clear conflict 400, 403 non-admin, audit old\u2192new, /{key} route ordering), generic PUT/DELETE compatibility, brain-orb route gate honoring a real DB flip without restart, and feature-flags composition (voice = base \u2227 voice \u2227 key; 200 despite brain-orb DB failure)." + "description": "Admin-configurable Brain Orb flags (trinity-enterprise#85): _resolve_bool_flag order (stored wins both directions, env opt-in, default OFF, junk-value fail-safe, fail-open on DB error), GET/PUT /api/settings/brain-orb (per-flag source, partial update, clear revert-to-env, set+clear conflict 400, 403 non-admin, audit old→new, /{key} route ordering), generic PUT/DELETE compatibility, brain-orb route gate honoring a real DB flip without restart, and feature-flags composition (voice = base ∧ voice ∧ key; 200 despite brain-orb DB failure)." }, { "file": "unit/test_1332_cancelled_activity_state.py", @@ -152,7 +152,7 @@ "activities", "observability" ], - "description": "A user-cancelled execution's dispatch activity is recorded as ActivityState.CANCELLED, not FAILED. Covers the enum value + activity_state_for_terminal mapping helper, the operator-terminate handler (Path B) closing the open dispatch activity as CANCELLED (terminated\u2192close, already_finished\u2192no-op, no-activity\u2192no-op, close-raises\u2192swallowed), and the collaboration/self-task close helpers mapping a cancelled result to CANCELLED (#1332)." + "description": "A user-cancelled execution's dispatch activity is recorded as ActivityState.CANCELLED, not FAILED. Covers the enum value + activity_state_for_terminal mapping helper, the operator-terminate handler (Path B) closing the open dispatch activity as CANCELLED (terminated→close, already_finished→no-op, no-activity→no-op, close-raises→swallowed), and the collaboration/self-task close helpers mapping a cancelled result to CANCELLED (#1332)." }, { "file": "unit/test_voip_audio.py", @@ -164,7 +164,7 @@ "voip", "audio" ], - "description": "Audio codec round-trip + stateful ratecv continuity (anti-click) + 160-byte framing for the Twilio\u2194Gemini bridge (#1056). Skips where audioop/audioop-lts is unavailable." + "description": "Audio codec round-trip + stateful ratecv continuity (anti-click) + 160-byte framing for the Twilio↔Gemini bridge (#1056). Skips where audioop/audioop-lts is unavailable." }, { "file": "unit/test_voip_db.py", @@ -547,7 +547,7 @@ "lifecycle", "file-sharing" ], - "description": "check_public_folder_mount_matches truth table: enabled+mounted \u2192 True, enabled+unmounted \u2192 False (needs recreation to attach), disabled+mounted \u2192 False (needs recreation to detach), disabled+unmounted \u2192 True. Adversarial cases: similar paths (/public-backup, /public/inner) don't match, missing 'Mounts' key handled, flag re-read each call, other mounts (shared-out, shared-in/*, workspace) don't interfere (9 tests)" + "description": "check_public_folder_mount_matches truth table: enabled+mounted → True, enabled+unmounted → False (needs recreation to attach), disabled+mounted → False (needs recreation to detach), disabled+unmounted → True. Adversarial cases: similar paths (/public-backup, /public/inner) don't match, missing 'Mounts' key handled, flag re-read each call, other mounts (shared-out, shared-in/*, workspace) don't interfere (9 tests)" }, { "file": "unit/test_slack_dm_default.py", @@ -559,7 +559,7 @@ "db", "slack" ], - "description": "set_dm_default + unbind_agent contract: setter is single-tx clear-then-set, idempotent, exclusive (exactly one default per workspace), per-workspace isolation, returns False when agent not bound. Unbind is pure delete (does NOT auto-promote \u2014 router enforces the guard), works on non-default and last-agent paths, unknown agent returns False (10 tests)" + "description": "set_dm_default + unbind_agent contract: setter is single-tx clear-then-set, idempotent, exclusive (exactly one default per workspace), per-workspace isolation, returns False when agent not bound. Unbind is pure delete (does NOT auto-promote — router enforces the guard), works on non-default and last-agent paths, unknown agent returns False (10 tests)" }, { "file": "test_public_chat_history.py", @@ -571,7 +571,7 @@ "public", "chat" ], - "description": "Tests for GET /api/public/sessions/{token} and GET /api/public/sessions/{token}/{session_id} \u2014 auth requirements, 404 on invalid tokens, response shape, limit param" + "description": "Tests for GET /api/public/sessions/{token} and GET /api/public/sessions/{token}/{session_id} — auth requirements, 404 on invalid tokens, response shape, limit param" }, { "file": "unit/test_voice_tools.py", @@ -584,7 +584,7 @@ "gemini", "tools" ], - "description": "Unit tests for voice tool call support (#581): _execute_tool (success, empty prompt, truncation to 2000 chars, agent not reachable, task error), _execute_and_respond (success path with callbacks, timeout \u2192 error response, inactive session skips send), tool declaration (_RUN_TASK_TOOL name + required prompt), end_session cancels pending tool tasks (12 tests)" + "description": "Unit tests for voice tool call support (#581): _execute_tool (success, empty prompt, truncation to 2000 chars, agent not reachable, task error), _execute_and_respond (success path with callbacks, timeout → error response, inactive session skips send), tool declaration (_RUN_TASK_TOOL name + required prompt), end_session cancels pending tool tasks (12 tests)" }, { "file": "test_files_guardrail_bypass.py", @@ -623,7 +623,7 @@ "files", "chat" ], - "description": "Unit tests for web chat file upload (#364): sanitize_filename (path traversal, unicode, dedup, truncation), decode_web_file (data: URI prefix stripping, raw base64, empty/bad input), process_file_uploads (empty list, download failure, unsupported MIME, oversized file, image vision block collection, text file container write, max_files cap) \u2014 14 tests" + "description": "Unit tests for web chat file upload (#364): sanitize_filename (path traversal, unicode, dedup, truncation), decode_web_file (data: URI prefix stripping, raw base64, empty/bad input), process_file_uploads (empty list, download failure, unsupported MIME, oversized file, image vision block collection, text file container write, max_files cap) — 14 tests" }, { "file": "unit/test_slack_mrkdwn.py", @@ -649,7 +649,7 @@ "webhooks", "lint" ], - "description": "AST-based lint guard (no backend deps required) that asserts every db.(...) call in src/backend/routers/ and src/backend/services/ resolves to a real method on DatabaseManager. Two tests: strict regression check for the four WEBHOOK-001 methods (#647: generate_webhook_token, get_schedule_by_webhook_token, revoke_webhook_token, get_webhook_status) and a broad facade-resolution scan guarded by a KNOWN_FACADE_GAPS allowlist for eight unrelated pre-existing gaps. Catches AttributeError-at-runtime regressions that integration-only tests miss in CI \u2014 would have caught WEBHOOK-001 before #291 landed." + "description": "AST-based lint guard (no backend deps required) that asserts every db.(...) call in src/backend/routers/ and src/backend/services/ resolves to a real method on DatabaseManager. Two tests: strict regression check for the four WEBHOOK-001 methods (#647: generate_webhook_token, get_schedule_by_webhook_token, revoke_webhook_token, get_webhook_status) and a broad facade-resolution scan guarded by a KNOWN_FACADE_GAPS allowlist for eight unrelated pre-existing gaps. Catches AttributeError-at-runtime regressions that integration-only tests miss in CI — would have caught WEBHOOK-001 before #291 landed." }, { "file": "unit/test_slack_token_encryption.py", @@ -677,7 +677,7 @@ "credentials", "encryption" ], - "description": "Unit tests for Telegram bot token encryption in db/telegram_channels.py (#664): round-trip via TelegramChannelOperations.create_binding + get_decrypted_bot_token, raw DB value is AES-256-GCM JSON envelope, get_binding_by_agent returns the encrypted blob (plaintext only via accessor), corrupt envelope returns None with ERROR log, wrong-key envelope returns None, re-encryption on update produces a fresh nonce (and token rotation reflects the new plaintext), encrypt raises on missing CREDENTIAL_ENCRYPTION_KEY, decrypt swallows missing-key error and returns None. No plaintext fallback path \u2014 Telegram never shipped plaintext." + "description": "Unit tests for Telegram bot token encryption in db/telegram_channels.py (#664): round-trip via TelegramChannelOperations.create_binding + get_decrypted_bot_token, raw DB value is AES-256-GCM JSON envelope, get_binding_by_agent returns the encrypted blob (plaintext only via accessor), corrupt envelope returns None with ERROR log, wrong-key envelope returns None, re-encryption on update produces a fresh nonce (and token rotation reflects the new plaintext), encrypt raises on missing CREDENTIAL_ENCRYPTION_KEY, decrypt swallows missing-key error and returns None. No plaintext fallback path — Telegram never shipped plaintext." }, { "file": "unit/test_whatsapp_token_encryption.py", @@ -692,7 +692,7 @@ "credentials", "encryption" ], - "description": "Unit tests for Twilio AuthToken encryption in db/whatsapp_channels.py (#664): round-trip via WhatsAppChannelOperations.create_binding + get_decrypted_auth_token, raw DB value is AES-256-GCM JSON envelope, account_sid stays plaintext (public Twilio identifier \u2014 pinned so future refactors don't accidentally encrypt or strip it), corrupt envelope returns None, wrong-key envelope returns None, re-encryption on update produces a fresh nonce, encrypt raises on missing CREDENTIAL_ENCRYPTION_KEY, decrypt returns None on missing key." + "description": "Unit tests for Twilio AuthToken encryption in db/whatsapp_channels.py (#664): round-trip via WhatsAppChannelOperations.create_binding + get_decrypted_auth_token, raw DB value is AES-256-GCM JSON envelope, account_sid stays plaintext (public Twilio identifier — pinned so future refactors don't accidentally encrypt or strip it), corrupt envelope returns None, wrong-key envelope returns None, re-encryption on update produces a fresh nonce, encrypt raises on missing CREDENTIAL_ENCRYPTION_KEY, decrypt returns None on missing key." }, { "file": "unit/test_slack_workspaces_encryption.py", @@ -751,7 +751,7 @@ "resilience", "circuit-breaker" ], - "description": "Tests that the AgentClient circuit breaker only counts TCP unreachability (connect errors/timeouts) toward opening \u2014 HTTP errors, read/write/pool failures, and protocol errors are excluded" + "description": "Tests that the AgentClient circuit breaker only counts TCP unreachability (connect errors/timeouts) toward opening — HTTP errors, read/write/pool failures, and protocol errors are excluded" }, { "file": "integration/test_circuit_breaker.py", @@ -765,7 +765,7 @@ "circuit-breaker", "redis" ], - "description": "Drives the real AgentClient through httpx.MockTransport against real Redis circuit state \u2014 hard vs soft failures, mixed interleave, open-circuit fast-fail, recovery on 200, and the deferred half-open soft-failure probe-lock behaviour" + "description": "Drives the real AgentClient through httpx.MockTransport against real Redis circuit state — hard vs soft failures, mixed interleave, open-circuit fast-fail, recovery on 200, and the deferred half-open soft-failure probe-lock behaviour" }, { "file": "integration/test_monitoring_service.py", @@ -778,7 +778,7 @@ "monitoring", "circuit-breaker" ], - "description": "Mirrors the #474 classification rule on the /health probe path and pins the `status_code >= 500 \u2192 UNHEALTHY` aggregator branch" + "description": "Mirrors the #474 classification rule on the /health probe path and pins the `status_code >= 500 → UNHEALTHY` aggregator branch" }, { "file": "unit/test_platform_default_model_regression.py", @@ -849,7 +849,7 @@ "migrations", "reliability" ], - "description": "Migration runner atomicity + cross-process lock (#1160). _atomic_rebuild rename-swap inside an explicit transaction closes the DROP-rebuild data-loss window (crash mid-rebuild rolls back with no data loss; verified for agent_sharing + agent_skills incl. lowercase/NULL-email behavior preservation and index recreation); replay-after-partial-apply completes cleanly; a failed migration is named on its traceback via add_note (original exception type preserved). Cross-process flock (db/migration_lock.py) serialises concurrent boots \u2014 multiprocessing tests prove mutual exclusion and a safe concurrent rebuild." + "description": "Migration runner atomicity + cross-process lock (#1160). _atomic_rebuild rename-swap inside an explicit transaction closes the DROP-rebuild data-loss window (crash mid-rebuild rolls back with no data loss; verified for agent_sharing + agent_skills incl. lowercase/NULL-email behavior preservation and index recreation); replay-after-partial-apply completes cleanly; a failed migration is named on its traceback via add_note (original exception type preserved). Cross-process flock (db/migration_lock.py) serialises concurrent boots — multiprocessing tests prove mutual exclusion and a safe concurrent rebuild." }, { "file": "unit/test_compatibility_checks.py", @@ -874,7 +874,7 @@ "voip", "voice" ], - "description": "Per-agent persisted voice + VoIP enable/disable toggle (#28). db/agents.py get_voice_name/set_voice_name: unset->'Kore' fallback, set/get roundtrip, invalid-persisted-value->default (reviewer M1 read-path validation), clear->default, set-on-missing-agent->False. db/voip.py set_enabled toggle + the create_binding 'preserve enabled on re-PUT' fix (reviewer H3 \u2014 re-saving credentials on a disabled binding must not silently re-enable it) + set_enabled on a missing binding returns False (router 404s). config.GEMINI_VOICE_NAMES default + parity guard asserting the frontend src/constants/voices.js VOICE ids and DEFAULT_VOICE_NAME mirror the backend constants (reviewer M2 cross-language drift guard). Runs on db_harness backends (SQLite always, PostgreSQL when TEST_POSTGRES_URL set); no Docker/API/Redis." + "description": "Per-agent persisted voice + VoIP enable/disable toggle (#28). db/agents.py get_voice_name/set_voice_name: unset->'Kore' fallback, set/get roundtrip, invalid-persisted-value->default (reviewer M1 read-path validation), clear->default, set-on-missing-agent->False. db/voip.py set_enabled toggle + the create_binding 'preserve enabled on re-PUT' fix (reviewer H3 — re-saving credentials on a disabled binding must not silently re-enable it) + set_enabled on a missing binding returns False (router 404s). config.GEMINI_VOICE_NAMES default + parity guard asserting the frontend src/constants/voices.js VOICE ids and DEFAULT_VOICE_NAME mirror the backend constants (reviewer M2 cross-language drift guard). Runs on db_harness backends (SQLite always, PostgreSQL when TEST_POSTGRES_URL set); no Docker/API/Redis." }, { "file": "unit/test_28_voip_voice_endpoints.py", @@ -958,7 +958,7 @@ "infrastructure", "brain-orb" ], - "description": "Cornelius first-run seeder (ent#107): ensure_seeded first-run gating via durable cornelius_seeded flag (deleted agent not resurrected); fresh-install scoping (existing non-system agents -> skip + converge flag, orb flag untouched); owner-must-exist deferral (pre-setup skip without burning the flag); Docker-unavailable skip; Brain Orb flag defaulted ON existence-guarded (admin OFF preserved); 409-on-exists convergence vs generic-failure retry (flag not burned, never raises); --workers 2 Redis SETNX provision lock (held -> skip, winner provisions+releases with TTL, Redis-down fail-open); _provision builds a github:Abilityai/cornelius create with request=None and source_mode default True (#1656 \u2014 the trinity-enterprise#123 tokenless public-clone path is source-mode only); real-DB smoke of db.count_non_system_agents() facade delegation. create_agent_internal/db/redis/docker seams patched; no Docker/backend. ent#124 addition: precomputed freshness verdict \u2014 ensure_seeded(fresh=True) skips the internal count, fresh=False converges the flag without provisioning, fresh=None preserves the legacy count path." + "description": "Cornelius first-run seeder (ent#107): ensure_seeded first-run gating via durable cornelius_seeded flag (deleted agent not resurrected); fresh-install scoping (existing non-system agents -> skip + converge flag, orb flag untouched); owner-must-exist deferral (pre-setup skip without burning the flag); Docker-unavailable skip; Brain Orb flag defaulted ON existence-guarded (admin OFF preserved); 409-on-exists convergence vs generic-failure retry (flag not burned, never raises); --workers 2 Redis SETNX provision lock (held -> skip, winner provisions+releases with TTL, Redis-down fail-open); _provision builds a github:Abilityai/cornelius create with request=None and source_mode default True (#1656 — the trinity-enterprise#123 tokenless public-clone path is source-mode only); real-DB smoke of db.count_non_system_agents() facade delegation. create_agent_internal/db/redis/docker seams patched; no Docker/backend. ent#124 addition: precomputed freshness verdict — ensure_seeded(fresh=True) skips the internal count, fresh=False converges the flag without provisioning, fresh=None preserves the legacy count path." }, { "file": "unit/test_1557_autonomy_breaker_decoupled.py", @@ -970,7 +970,7 @@ "circuit-breaker", "autonomy" ], - "description": "Disabling autonomy must not touch the circuit breaker (#1557). Structural guards: autonomy.py no longer references force_circuit_dormant/reset_circuit (regression guard \u2014 fails on pre-#1557 source) and still calls set_schedule_enabled (proactive suppression intact). Message honesty: _circuit_breaker_error names transport-unreachable vs dispatch-auth-dead, and every branch keeps the 'circuit breaker open' substring pinned by the #1560 integration test." + "description": "Disabling autonomy must not touch the circuit breaker (#1557). Structural guards: autonomy.py no longer references force_circuit_dormant/reset_circuit (regression guard — fails on pre-#1557 source) and still calls set_schedule_enabled (proactive suppression intact). Message honesty: _circuit_breaker_error names transport-unreachable vs dispatch-auth-dead, and every branch keeps the 'circuit breaker open' substring pinned by the #1560 integration test." }, { "file": "integration/test_1557_autonomy_inbound.py", @@ -1031,7 +1031,7 @@ "proactive-messaging", "regression" ], - "description": "Proactive messages persist to channel session history (#1600). Core property is session-identifier EQUALITY: the key the proactive path derives must equal the key an inbound DM resolves to, per channel (telegram/whatsapp/slack) \u2014 persisting into a different session looks like a fix but leaves the agent unaware of its own outreach. Also: #903 attribution (assistant role, agent sender_label, recipient sender_email for the single-participant DM), persist only on confirmed delivery (no phantom turn on failure), fail-soft on DB error (message already sent), session created when absent (the chat-link session_id column is never written by any code path), and access-grant notifications deliberately NOT persisted (#951 out of scope)." + "description": "Proactive messages persist to channel session history (#1600). Core property is session-identifier EQUALITY: the key the proactive path derives must equal the key an inbound DM resolves to, per channel (telegram/whatsapp/slack) — persisting into a different session looks like a fix but leaves the agent unaware of its own outreach. Also: #903 attribution (assistant role, agent sender_label, recipient sender_email for the single-participant DM), persist only on confirmed delivery (no phantom turn on failure), fail-soft on DB error (message already sent), session created when absent (the chat-link session_id column is never written by any code path), and access-grant notifications deliberately NOT persisted (#951 out of scope)." }, { "file": "unit/test_1649_group_message_history.py", @@ -1043,7 +1043,7 @@ "proactive-messaging", "regression" ], - "description": "Proactive GROUP messages persist to channel session history (#1649). Slack is a real recall fix: the broadcast is filed at the posted message's own ts, which IS the thread key an in-thread reply resolves to (asserted against the adapter). Telegram is bookkeeping only \u2014 group sessions are per-(sender, chat) with no group branch, so a broadcast uses a synthetic agent-sender key; a test pins that it deliberately does NOT match a participant's session, so the accepted trade-off can't be mistaken for a bug and a future adapter group-branch forces a re-decision. Router-level tests drive the real endpoints (the #1600 lesson: helper-only tests passed with the persistence call deleted outright). Also: #903 shared-thread attribution (sender_email=None), persist only on confirmed delivery, fail-soft, and send_message_detailed's ts capture keeping send_message's 2-tuple contract for its ~7 callers." + "description": "Proactive GROUP messages persist to channel session history (#1649). Slack is a real recall fix: the broadcast is filed at the posted message's own ts, which IS the thread key an in-thread reply resolves to (asserted against the adapter). Telegram is bookkeeping only — group sessions are per-(sender, chat) with no group branch, so a broadcast uses a synthetic agent-sender key; a test pins that it deliberately does NOT match a participant's session, so the accepted trade-off can't be mistaken for a bug and a future adapter group-branch forces a re-decision. Router-level tests drive the real endpoints (the #1600 lesson: helper-only tests passed with the persistence call deleted outright). Also: #903 shared-thread attribution (sender_email=None), persist only on confirmed delivery, fail-soft, and send_message_detailed's ts capture keeping send_message's 2-tuple contract for its ~7 callers." }, { "file": "unit/test_1632_operator_queue_caps.py", @@ -1056,7 +1056,7 @@ "security", "reliability" ], - "description": "Operator-queue create-path ingestion caps (#1632): the agent-authored sync boundary is bounded by a DB-measured pending-DEPTH cap (primary, Redis-independent), a per-agent + fleet RATE cap (fail-open, break on deny, real in-process-fallback exercise), a total truncate-with-marker field-hygiene clamp inside the #1525 try/except (title/question/context/options/execution_id/created_at/priority, non-dict context, boundary lengths, never-raises), the reserved-platform-id + malformed-id guard, the opqueue:leader cross-worker lock (non-leader poll cycle is a no-op), the one-per-episode flood alert (un-guessable id, cooldown, emit-failure-safe, platform-exempt), the oversize-file skip, and the generous DB-sink belt + count_pending_for_agent helper. Also pins the concrete platform producer validation_service._notify_operator_on_failure, converted to a direct db.create_operator_queue_item (#1632): reserved `val_` id (an agent can't pre-create and thereby suppress its own validation alarm), bypasses the agent-file sync caps, and stays best-effort (a create failure never fails validation) \u2014 the conversion also fixed a latent bug where the notification appended to a bare list and was never ingested. Also pins the _leader_ttl 30s floor (F1: lease outlasts one worst-case cycle + sleep, so leadership doesn't flap and the flood alert doesn't double-emit under --workers 2). Pure/mocked (rate_limiter stubbed + one real in-process test; DB engine stubbed)." + "description": "Operator-queue create-path ingestion caps (#1632): the agent-authored sync boundary is bounded by a DB-measured pending-DEPTH cap (primary, Redis-independent), a per-agent + fleet RATE cap (fail-open, break on deny, real in-process-fallback exercise), a total truncate-with-marker field-hygiene clamp inside the #1525 try/except (title/question/context/options/execution_id/created_at/priority, non-dict context, boundary lengths, never-raises), the reserved-platform-id + malformed-id guard, the opqueue:leader cross-worker lock (non-leader poll cycle is a no-op), the one-per-episode flood alert (un-guessable id, cooldown, emit-failure-safe, platform-exempt), the oversize-file skip, and the generous DB-sink belt + count_pending_for_agent helper. Also pins the concrete platform producer validation_service._notify_operator_on_failure, converted to a direct db.create_operator_queue_item (#1632): reserved `val_` id (an agent can't pre-create and thereby suppress its own validation alarm), bypasses the agent-file sync caps, and stays best-effort (a create failure never fails validation) — the conversion also fixed a latent bug where the notification appended to a bare list and was never ingested. Also pins the _leader_ttl 30s floor (F1: lease outlasts one worst-case cycle + sleep, so leadership doesn't flap and the flood alert doesn't double-emit under --workers 2). Pure/mocked (rate_limiter stubbed + one real in-process test; DB engine stubbed)." }, { "file": "unit/test_1615_ssh_password_removed.py", @@ -1068,7 +1068,7 @@ "security", "regression" ], - "description": "Password SSH auth removed (#1615, router surface): an explicit auth_method='password' returns 400 naming key auth as the alternative \u2014 not the pre-fix 500 (ModuleNotFoundError: crypt, removed from the stdlib in Python 3.13) and not a silent fall-through; refusal is case-insensitive and covers unknown methods; the request never reaches the container; key auth still works and remains the default; the response carries neither private_key (#175) nor password. Complements test_ssh_service.py, which guards the service layer (helpers deleted, no crypt import)." + "description": "Password SSH auth removed (#1615, router surface): an explicit auth_method='password' returns 400 naming key auth as the alternative — not the pre-fix 500 (ModuleNotFoundError: crypt, removed from the stdlib in Python 3.13) and not a silent fall-through; refusal is case-insensitive and covers unknown methods; the request never reaches the container; key auth still works and remains the default; the response carries neither private_key (#175) nor password. Complements test_ssh_service.py, which guards the service layer (helpers deleted, no crypt import)." }, { "file": "unit/test_ent162_per_user_github_pat.py", @@ -1094,7 +1094,7 @@ "live", "integration" ], - "description": "Live SSH-access API tests (#1615). Password auth is refused with 400 and never 500 (the pre-fix ModuleNotFoundError: crypt on Python 3.13) \u2014 including with a valid public_key supplied, which is what distinguishes the guard from an incidental missing-key 400; refusal is case-insensitive and covers unknown methods. Key auth (BYOK) returns connection details, is the default, clamps TTL, and leaks neither private_key (#175) nor password. Marked `integration`: an ed25519 key injected through the API is used for a REAL ssh login into the container (proves key auth works \u2014 mocks cannot), and a password login is proven impossible against the agent sshd's own PasswordAuthentication=no. sshd readiness is polled via the SSH banner so the e2e tests don't race container boot." + "description": "Live SSH-access API tests (#1615). Password auth is refused with 400 and never 500 (the pre-fix ModuleNotFoundError: crypt on Python 3.13) — including with a valid public_key supplied, which is what distinguishes the guard from an incidental missing-key 400; refusal is case-insensitive and covers unknown methods. Key auth (BYOK) returns connection details, is the default, clamps TTL, and leaks neither private_key (#175) nor password. Marked `integration`: an ed25519 key injected through the API is used for a REAL ssh login into the container (proves key auth works — mocks cannot), and a password login is proven impossible against the agent sshd's own PasswordAuthentication=no. sshd readiness is polled via the SSH banner so the e2e tests don't race container boot." }, { "file": "unit/test_1673_execution_error_not_success.py", @@ -1141,7 +1141,7 @@ "agents", "unit" ], - "description": "Per-agent display label (ent#181): a human-facing name that is rendered, never resolved \u2014 the slug (agent_name) stays the identity every route, container, volume, MCP key and A2A card keys on. Pins the point of the feature: setting a label leaves the slug AND its #1664 volume identity untouched, does not reserve a name, and two agents may share one (labels aren't identities). NULL = render the slug (no backfill; clearing reverts rather than blanking); a blank/whitespace label stores NULL, not an empty string that would render a nameless agent. Soft-deleted agents are not editable (deleted_at guard, mirroring the other settings setters). Batch read for the fleet list (absent = no label = slug) so the hottest endpoint stays 1 query. Router: owner-gated PUT, `label` never coerced to the slug on read (the UI must tell 'no label' from 'label equals slug'), null clears, 404 when the row vanished, WS agent_label_changed broadcast." + "description": "Per-agent display label (ent#181): a human-facing name that is rendered, never resolved — the slug (agent_name) stays the identity every route, container, volume, MCP key and A2A card keys on. Pins the point of the feature: setting a label leaves the slug AND its #1664 volume identity untouched, does not reserve a name, and two agents may share one (labels aren't identities). NULL = render the slug (no backfill; clearing reverts rather than blanking); a blank/whitespace label stores NULL, not an empty string that would render a nameless agent. Soft-deleted agents are not editable (deleted_at guard, mirroring the other settings setters). Batch read for the fleet list (absent = no label = slug) so the hottest endpoint stays 1 query. Router: owner-gated PUT, `label` never coerced to the slug on read (the UI must tell 'no label' from 'label equals slug'), null clears, 404 when the row vanished, WS agent_label_changed broadcast." }, { "file": "unit/test_1484_create_agent_characterization.py", @@ -1164,7 +1164,7 @@ "skills", "unit" ], - "description": "Full-directory skill packages (ent#183): pure packaging primitives (hardened frontmatter contract parse \u2014 alias-bomb refused, garbage requires typed-guarded, malicious dep names regex-gated; git-archive member vetting \u2014 REGTYPE only, symlinks/litter/protected basenames dropped with named warnings; injection tar with generated .trinity-skill.json meta appended LAST; manifest-based prune diff capped 200/skill); round-trip against the REAL agent-server restore_from_tar incl. allowlist confinement; real-git end-to-end (repo -> archive -> filter -> restore, tree-SHA determinism across clones, exec bits from git modes); injection orchestration (skip-if-unchanged vs force, old-image 404 fallback with multi_file_dropped_old_image only for multi-file skills, restore-failure repair path = delete-dir + one re-restore, prune deletes ONLY previous-manifest files, unmanaged same-named dir overwritten but never pruned, per-skill + total caps named errors, dep-probe warnings missing_binary/missing_env fail-open and run for unchanged skills too, SkillInjectionBusy on lock contention, CLAUDE.md section rebuilt from ALL present skills with line-anchored replacement that spares ### lookalikes)." + "description": "Full-directory skill packages (ent#183): pure packaging primitives (hardened frontmatter contract parse — alias-bomb refused, garbage requires typed-guarded, malicious dep names regex-gated; git-archive member vetting — REGTYPE only, symlinks/litter/protected basenames dropped with named warnings; injection tar with generated .trinity-skill.json meta appended LAST; manifest-based prune diff capped 200/skill); round-trip against the REAL agent-server restore_from_tar incl. allowlist confinement; real-git end-to-end (repo -> archive -> filter -> restore, tree-SHA determinism across clones, exec bits from git modes); injection orchestration (skip-if-unchanged vs force, old-image 404 fallback with multi_file_dropped_old_image only for multi-file skills, restore-failure repair path = delete-dir + one re-restore, prune deletes ONLY previous-manifest files, unmanaged same-named dir overwritten but never pruned, per-skill + total caps named errors, dep-probe warnings missing_binary/missing_env fail-open and run for unchanged skills too, SkillInjectionBusy on lock contention, CLAUDE.md section rebuilt from ALL present skills with line-anchored replacement that spares ### lookalikes)." }, { "file": "unit/test_ent123_tokenless_clone.py", @@ -1176,7 +1176,7 @@ "git", "unit" ], - "description": "PAT-free clone of public github: templates (ent#123). 50 tests over the 1484-shape purge-and-mock harness: _gate_tokenless_request (\"\"->None normalization, source-mode-only 400 incl. explicit None, fork passthrough), _parse_github_ref charset guard (eval-interpolation hardening), _validate_github_access tokenless ls-remote probe (ok/unavailable->400/transient->502 fail-closed, anonymous branch check, PAT-ful REST regression), _apply_github_env token-var gating (+GIT_SYNC_AUTO belt), lifecycle._apply_persisted_auth_env rebuild seam (repo-only gate, source-mode re-derivation), git_service.probe_anonymous_repo_access stderr classification, the no_write_credentials sync/reset guard (baked env OR per-agent PAT row \u2014 #1264 live-injection window, fail-open), and startup.sh static guards (repo-only clone gate, credential-less CLONE_URL, GIT_TERMINAL_PROMPT=0, push blackhole, .env PAT fallback, private-repo failure cause)." + "description": "PAT-free clone of public github: templates (ent#123). 50 tests over the 1484-shape purge-and-mock harness: _gate_tokenless_request (\"\"->None normalization, source-mode-only 400 incl. explicit None, fork passthrough), _parse_github_ref charset guard (eval-interpolation hardening), _validate_github_access tokenless ls-remote probe (ok/unavailable->400/transient->502 fail-closed, anonymous branch check, PAT-ful REST regression), _apply_github_env token-var gating (+GIT_SYNC_AUTO belt), lifecycle._apply_persisted_auth_env rebuild seam (repo-only gate, source-mode re-derivation), git_service.probe_anonymous_repo_access stderr classification, the no_write_credentials sync/reset guard (baked env OR per-agent PAT row — #1264 live-injection window, fail-open), and startup.sh static guards (repo-only clone gate, credential-less CLONE_URL, GIT_TERMINAL_PROMPT=0, push blackhole, .env PAT fallback, private-repo failure cause)." }, { "file": "unit/test_ent125_resilient_system_deploy.py", @@ -1187,7 +1187,7 @@ "systems", "unit" ], - "description": "Resilient system-manifest deploy (ent#125): best-effort default \u2014 partial deploy continues past a failed agent-create and reports failed[] with {name, short_name, template, reason, status_code}; post-create config (folders/permissions/schedules/tags) scoped to the survivor map; config-phase failures degrade to warnings; total failure returns HTTP 500 with the full report body, skips config/start, and never writes trinity_prompt; partial still writes it; strict=True aborts preserving the original status code (dict-detail 429 not flattened to 500); reason normalization (dict detail \u2192 error field), PAT-bearing git-URL userinfo redaction (learnings 2026-07-14), and 500-char truncation; orchestrator-workers preset with failed orchestrator warns 'non-functional' and configures permissions over survivors only; dry_run and all-success responses unchanged (failed == []). Router mounted alone; since the ent#124 extraction the deploy orchestration lives in services.system_service.deploy_manifest, so config fns/db are patched on services.system_service and agent creation via the _default_create_agent_fn seam." + "description": "Resilient system-manifest deploy (ent#125): best-effort default — partial deploy continues past a failed agent-create and reports failed[] with {name, short_name, template, reason, status_code}; post-create config (folders/permissions/schedules/tags) scoped to the survivor map; config-phase failures degrade to warnings; total failure returns HTTP 500 with the full report body, skips config/start, and never writes trinity_prompt; partial still writes it; strict=True aborts preserving the original status code (dict-detail 429 not flattened to 500); reason normalization (dict detail → error field), PAT-bearing git-URL userinfo redaction (learnings 2026-07-14), and 500-char truncation; orchestrator-workers preset with failed orchestrator warns 'non-functional' and configures permissions over survivors only; dry_run and all-success responses unchanged (failed == []). Router mounted alone; since the ent#124 extraction the deploy orchestration lives in services.system_service.deploy_manifest, so config fns/db are patched on services.system_service and agent creation via the _default_create_agent_fn seam." }, { "file": "unit/test_ent124_default_system_seed.py", @@ -1199,7 +1199,7 @@ "unit", "infrastructure" ], - "description": "First-run default-system seeder (ent#124): the REAL bundled config/manifests/default-system.yaml validates through the executor's own parse_manifest/validate_manifest (acme trio, full-mesh, no schedules, no prompt) and every local: template it references exists in-tree (learnings 2026-07-23 blank-agent trap); durable default_system_seeded flag gating (deleted fleet never resurrected); persisted first_run_fresh verdict \u2014 computed once BEFORE Cornelius runs, persisted, reused on later passes (cross-pass count-poisoning bug), cornelius_seeded=true forces not-fresh (ent#107-era installs get no fleet), count failure defers without persisting; TRINITY_DEFAULT_SYSTEM_MANIFEST disable sentinels skip without burning the flag; override path used when set, unreadable override fails loudly (operator-queue alert, no bundled fallback, no flag); flag policy per deploy outcome: deployed/partial set flag (+partial alert), failed (0 created)/exception do NOT (retry-safe); existence backstop converges the flag without deploying when any {system}-{short} name is reserved (suffix double-seed guard for the fail-open lock); SETNX lock held->skip / winner deploys+releases; orchestrator never raises when Cornelius explodes. db/docker/redis/deploy_manifest seams patched; no Docker/backend." + "description": "First-run default-system seeder (ent#124): the REAL bundled config/manifests/default-system.yaml validates through the executor's own parse_manifest/validate_manifest (acme trio, full-mesh, no schedules, no prompt) and every local: template it references exists in-tree (learnings 2026-07-23 blank-agent trap); durable default_system_seeded flag gating (deleted fleet never resurrected); persisted first_run_fresh verdict — computed once BEFORE Cornelius runs, persisted, reused on later passes (cross-pass count-poisoning bug), cornelius_seeded=true forces not-fresh (ent#107-era installs get no fleet), count failure defers without persisting; TRINITY_DEFAULT_SYSTEM_MANIFEST disable sentinels skip without burning the flag; override path used when set, unreadable override fails loudly (operator-queue alert, no bundled fallback, no flag); flag policy per deploy outcome: deployed/partial set flag (+partial alert), failed (0 created)/exception do NOT (retry-safe); existence backstop converges the flag without deploying when any {system}-{short} name is reserved (suffix double-seed guard for the fail-open lock); SETNX lock held->skip / winner deploys+releases; orchestrator never raises when Cornelius explodes. db/docker/redis/deploy_manifest seams patched; no Docker/backend." }, { "file": "unit/test_1809_image_drift_recreate.py", @@ -1211,7 +1211,7 @@ "unit", "reliability" ], - "description": "Image-drift recreate predicate (#1809): check_base_image_matches compares the container's resolved image id against what its OWN Config.Image reference currently resolves to \u2014 drift\u2192False (recreate on next cold start), match\u2192True; fail-open True on falsy attrs (Config.Image can be \"\"), ImageNotFound (recreate would fail on the same missing tag), and generic daemon errors (2am case: start proceeds on the old image, never a fleet recreate); ID-pinned Config.Image is a documented tautological no-op; version-pinned tags compare their own tag only (a :latest-only rebuild leaves 0.8.0-pinned agents untouched). Source-pins the lifecycle wiring: lazy evaluation gated on `not needs_recreation and not was_already_running` (cold start only \u2014 start-on-running stays an idempotent no-op), ephemeral-ghost exclusion, image-check assignment ABOVE the #1560 clear_agent_breakers gate, recreate delivering the upgrade via the container's own tag, trinity.base-image-version label refresh, recreate-race hardening (remove NotFound tolerated, run 409 adopts the winner), start-response recreated/recreate_reason surfacing, and the skip-inject _reset fixture stubbing the new predicate (Mock-auto-child trap)." + "description": "Image-drift recreate predicate (#1809): check_base_image_matches compares the container's resolved image id against what its OWN Config.Image reference currently resolves to — drift→False (recreate on next cold start), match→True; fail-open True on falsy attrs (Config.Image can be \"\"), ImageNotFound (recreate would fail on the same missing tag), and generic daemon errors (2am case: start proceeds on the old image, never a fleet recreate); ID-pinned Config.Image is a documented tautological no-op; version-pinned tags compare their own tag only (a :latest-only rebuild leaves 0.8.0-pinned agents untouched). Source-pins the lifecycle wiring: lazy evaluation gated on `not needs_recreation and not was_already_running` (cold start only — start-on-running stays an idempotent no-op), ephemeral-ghost exclusion, image-check assignment ABOVE the #1560 clear_agent_breakers gate, recreate delivering the upgrade via the container's own tag, trinity.base-image-version label refresh, recreate-race hardening (remove NotFound tolerated, run 409 adopts the winner), start-response recreated/recreate_reason surfacing, and the skip-inject _reset fixture stubbing the new predicate (Mock-auto-child trap)." }, { "file": "unit/test_1759_local_template_not_found.py", @@ -1223,7 +1223,7 @@ "unit", "reliability" ], - "description": "Local-template create gate (#1759, companion to #1793). Drives the FULL create_agent_internal, so unlike test_1793_unknown_local_template.py it also proves the reject is pre-side-effect (no container, volume, MCP key, ownership row or ephemeral slot). Covers the absent case at #1793's 404 UNKNOWN_LOCAL_TEMPLATE (both roots missing, dir without template.yaml, name resolving to a regular file), the #1759 400 LOCAL_TEMPLATE_INVALID band (empty / whitespace / comment-only / scalar / list / unparseable template.yaml), INVALID_LOCAL_TEMPLATE_NAME precedence, Blank Agent passthrough, the /template bind source under set/unset/EMPTY HOST_TEMPLATES_PATH, and the disclosure rule \u2014 one identical message whichever root missed, echoing no filesystem path (deploy-local template dirs are named after AGENT names, so 'which root' is a #186 enumeration oracle)." + "description": "Local-template create gate (#1759, companion to #1793). Drives the FULL create_agent_internal, so unlike test_1793_unknown_local_template.py it also proves the reject is pre-side-effect (no container, volume, MCP key, ownership row or ephemeral slot). Covers the absent case at #1793's 404 UNKNOWN_LOCAL_TEMPLATE (both roots missing, dir without template.yaml, name resolving to a regular file), the #1759 400 LOCAL_TEMPLATE_INVALID band (empty / whitespace / comment-only / scalar / list / unparseable template.yaml), INVALID_LOCAL_TEMPLATE_NAME precedence, Blank Agent passthrough, the /template bind source under set/unset/EMPTY HOST_TEMPLATES_PATH, and the disclosure rule — one identical message whichever root missed, echoing no filesystem path (deploy-local template dirs are named after AGENT names, so 'which root' is a #186 enumeration oracle)." }, { "file": "unit/test_1759_template_root_parity.py", @@ -1235,7 +1235,7 @@ "unit", "reliability" ], - "description": "Curated-template root parity (#1759). The create resolver's repo-relative fallback is hand-rolled rather than imported from template_service (which the #1484 harness MagicMocks, so an imported gate would be satisfied by a truthy mock), which makes drift between the two surfaces invisible \u2014 these tests pin them equal against both REAL modules. Also pins that the fallback resolves to /config/agent-templates and not /src/config/agent-templates (crud.py sits one directory deeper than template_service.py), that the deploy-local root is unchanged, that the container branch returns the pre-#1759 literal byte-identically, that the host branch is absolute so Docker accepts it as a bind source, and \u2014 the guard that would have caught the missing local:default \u2014 that EVERY shipped in-tree template resolves through the create gate." + "description": "Curated-template root parity (#1759). The create resolver's repo-relative fallback is hand-rolled rather than imported from template_service (which the #1484 harness MagicMocks, so an imported gate would be satisfied by a truthy mock), which makes drift between the two surfaces invisible — these tests pin them equal against both REAL modules. Also pins that the fallback resolves to /config/agent-templates and not /src/config/agent-templates (crud.py sits one directory deeper than template_service.py), that the deploy-local root is unchanged, that the container branch returns the pre-#1759 literal byte-identically, that the host branch is absolute so Docker accepts it as a bind source, and — the guard that would have caught the missing local:default — that EVERY shipped in-tree template resolves through the create gate." }, { "file": "unit/test_1759_export_manifest_template.py", @@ -1246,7 +1246,7 @@ "systems", "unit" ], - "description": "export_manifest template round-trip (#1759). Blank Agents carry template: None (key PRESENT), and dict.get(key, default) returns the default only when the key is ABSENT \u2014 so the old local:business-assistant fallback was unreachable dead code and every template-less agent exported template: null, which SystemAgentConfig.template (non-Optional str) rejects on redeploy. Pins the `or` fix emitting local:default, pass-through of a real template, that the exported manifest both validates and names a template that exists in-tree, and that inferred agents are logged." + "description": "export_manifest template round-trip (#1759). Blank Agents carry template: None (key PRESENT), and dict.get(key, default) returns the default only when the key is ABSENT — so the old local:business-assistant fallback was unreachable dead code and every template-less agent exported template: null, which SystemAgentConfig.template (non-Optional str) rejects on redeploy. Pins the `or` fix emitting local:default, pass-through of a real template, that the exported manifest both validates and names a template that exists in-tree, and that inferred agents are logged." }, { "file": "unit/test_1771c_schedules_cas_edges.py", @@ -1286,7 +1286,7 @@ "analytics", "scheduling" ], - "description": "Edge-case matrix (sub-area B) for db/schedules/analytics.py on db_harness (#300): _TRIGGER_BUCKETS subset of _BUCKET_ORDER regression guard (the existing literal assertion omits 'Reminders'), unknown/empty/None trigger bucketing, zero-terminal day reports None not 0%, headline-0.0 spec gap (UNSPEC), terminal-based success_rate incl. the legacy 'error' alias, NULL-skipping context AVG, percentile-cap boundary, full-set avg vs sampled p95 (the locked data-source discipline), percentile pool 0/1/2 rows, offset-bearing day bucketing [SQLITE-ONLY], strict '>' window boundary with a frozen iso_cutoff, gap-filled contiguous timeline, and _schedule_command_label unicode/length boundaries. Also covers get_schedule_analytics end-to-end (agent-written tool_calls SHAPE guards \u2014 valid-JSON-not-a-list, list-of-scalars, dict-without-name, non-numeric duration \u2014 on BOTH that surface and get_agent_schedules_summary; the FAILED arm of the per-schedule timeline; and its own 0/1/2/3-row percentile ladder, a second implementation of the same statistics.quantiles arithmetic get_agent_analytics has)." + "description": "Edge-case matrix (sub-area B) for db/schedules/analytics.py on db_harness (#300): _TRIGGER_BUCKETS subset of _BUCKET_ORDER regression guard (the existing literal assertion omits 'Reminders'), unknown/empty/None trigger bucketing, zero-terminal day reports None not 0%, headline-0.0 spec gap (UNSPEC), terminal-based success_rate incl. the legacy 'error' alias, NULL-skipping context AVG, percentile-cap boundary, full-set avg vs sampled p95 (the locked data-source discipline), percentile pool 0/1/2 rows, offset-bearing day bucketing [SQLITE-ONLY], strict '>' window boundary with a frozen iso_cutoff, gap-filled contiguous timeline, and _schedule_command_label unicode/length boundaries. Also covers get_schedule_analytics end-to-end (agent-written tool_calls SHAPE guards — valid-JSON-not-a-list, list-of-scalars, dict-without-name, non-numeric duration — on BOTH that surface and get_agent_schedules_summary; the FAILED arm of the per-schedule timeline; and its own 0/1/2/3-row percentile ladder, a second implementation of the same statistics.quantiles arithmetic get_agent_analytics has)." }, { "file": "unit/test_1771c_schedules_analytics_properties.py", @@ -1372,7 +1372,7 @@ "scheduling", "agents" ], - "description": "Creation-time schedule materialization (ent#89) against a REAL DB with row read-back: both resolver branches populate declared_schedules (the \u00a70/R2 regression \u2014 the github: branch never populated template_data), the github fetch uses the creation-resolved PAT and the parsed @branch ref, enabled honored/defaulted, cron->cron_expression, name-match and intra-block idempotency, the ghost skip, a falsy create_schedule return counted as failed (R6), and non-fatality of a raising create_schedule / list_agent_schedules inside the rollback fence." + "description": "Creation-time schedule materialization (ent#89) against a REAL DB with row read-back: both resolver branches populate declared_schedules (the §0/R2 regression — the github: branch never populated template_data), the github fetch uses the creation-resolved PAT and the parsed @branch ref, enabled honored/defaulted, cron->cron_expression, name-match and intra-block idempotency, the ghost skip, a falsy create_schedule return counted as failed (R6), and non-fatality of a raising create_schedule / list_agent_schedules inside the rollback fence." }, { "file": "unit/test_ent89_manifest_no_duplicate.py", @@ -1478,7 +1478,7 @@ "lifecycle", "credentials" ], - "description": "Agent MCP key detect/self-heal/rotate (#1854). DB: create_agent_mcp_api_key born ACTIVE (tables.py has no column default -> NULL reads as revoked and cannot even deserialize); captured-id delete (not `id != new_id`, so a racing recreate_missing_container mint is not collateral), connector key survives, keep_id never removed, other agents unreachable; DELETE-not-deactivate proven durable across soft-delete -> recover (recover_agent_ownership reactivates every inactive per-agent row). spawned_by_key_id reconcile: `!= current` repairs a child stranded on an OLDER superseded id, leaves a foreign parent alone, idempotent, and enforce_agent_spawn_scope never 403s at any step of a rotation. Drift predicate: absent env, URL-missing (injection needs BOTH), hash-mismatch, system/ghost exempt, fail-safe on DB error. Headline AC: env_overrides carry BOTH TRINITY_MCP_API_KEY and TRINITY_MCP_URL, proven end-to-end through recreate_container_with_updated_config on an old container that had NEITHER, plus a source-order guard that overrides are applied LAST. Probe: ok/foreign_user_key/foreign_agent_key/unknown_key/not_configured/shadow_entry, stopped container -> unavailable not 500, only digests leave the container (no token, no .mcp.json body) plus a non-vacuous SOURCE guard that no future edit of the in-container script can start leaking one. Health `stale` fires when last_used_at predates recent executions; trinity-system reports exempt not a false missing. Rotation: system/ghost 409 BEFORE any mutation, Redis-down 503 (fail-CLOSED) and contention 409, stopped agent stays stopped on the DB-only path, post-removal failure keeps superseded keys and names the real state with no raw exception text, 409-adoption post-condition blocks deletion, clear_agent_breakers before the recreate, no plaintext or key hash in response/log/audit, rate limit 429. Plus the FR-7 guards on MCP key revoke/delete and connector key mint. Review additions: the self-heal BODY is pinned \u2014 it takes the SAME agent:mcp_key_regen lock as rotation and does NOTHING (no mint, no delete) when Redis is down or another heal/rotation holds it, because there is no per-agent start lock and an unserialised second heal deletes the key the first is about to bake in; it audits its own mint+DELETE; container-env health (env_absent/env_mismatch) outranks the usage-derived states and is fail-soft; `stale` survives a naive legacy timestamp; the 409-adoption post-condition is an EXACT constant-time key match, proven against a container carrying a same-20-char-prefix twin; and any non-`agent` scope (connector, portal_delegate, a future sixth) reads as foreign, never `unknown_key`." + "description": "Agent MCP key detect/self-heal/rotate (#1854). DB: create_agent_mcp_api_key born ACTIVE (tables.py has no column default -> NULL reads as revoked and cannot even deserialize); captured-id delete (not `id != new_id`, so a racing recreate_missing_container mint is not collateral), connector key survives, keep_id never removed, other agents unreachable; DELETE-not-deactivate proven durable across soft-delete -> recover (recover_agent_ownership reactivates every inactive per-agent row). spawned_by_key_id reconcile: `!= current` repairs a child stranded on an OLDER superseded id, leaves a foreign parent alone, idempotent, and enforce_agent_spawn_scope never 403s at any step of a rotation. Drift predicate: absent env, URL-missing (injection needs BOTH), hash-mismatch, system/ghost exempt, fail-safe on DB error. Headline AC: env_overrides carry BOTH TRINITY_MCP_API_KEY and TRINITY_MCP_URL, proven end-to-end through recreate_container_with_updated_config on an old container that had NEITHER, plus a source-order guard that overrides are applied LAST. Probe: ok/foreign_user_key/foreign_agent_key/unknown_key/not_configured/shadow_entry, stopped container -> unavailable not 500, only digests leave the container (no token, no .mcp.json body) plus a non-vacuous SOURCE guard that no future edit of the in-container script can start leaking one. Health `stale` fires when last_used_at predates recent executions; trinity-system reports exempt not a false missing. Rotation: system/ghost 409 BEFORE any mutation, Redis-down 503 (fail-CLOSED) and contention 409, stopped agent stays stopped on the DB-only path, post-removal failure keeps superseded keys and names the real state with no raw exception text, 409-adoption post-condition blocks deletion, clear_agent_breakers before the recreate, no plaintext or key hash in response/log/audit, rate limit 429. Plus the FR-7 guards on MCP key revoke/delete and connector key mint. Review additions: the self-heal BODY is pinned — it takes the SAME agent:mcp_key_regen lock as rotation and does NOTHING (no mint, no delete) when Redis is down or another heal/rotation holds it, because there is no per-agent start lock and an unserialised second heal deletes the key the first is about to bake in; it audits its own mint+DELETE; container-env health (env_absent/env_mismatch) outranks the usage-derived states and is fail-soft; `stale` survives a naive legacy timestamp; the 409-adoption post-condition is an EXACT constant-time key match, proven against a container carrying a same-20-char-prefix twin; and any non-`agent` scope (connector, portal_delegate, a future sixth) reads as foreign, never `unknown_key`." }, { "file": "unit/test_ent313_failed_creation_container_reclaim.py", @@ -1489,7 +1489,7 @@ "unit", "reliability" ], - "description": "Failed-creation container reclaim (ent#313): a create that fails after containers.run must leave no orphan container, no unreclaimable volume and no name-keyed Redis state. Covers both arrival shapes \u2014 handle in hand (later step raised) and NO handle (the reported 60s Docker read timeout, where the daemon created the container and the client never got it, so it is re-derived by name). Pins every fail-closed gate on that re-derived path, because a name resolves to another install's live agent on a shared Docker daemon: 409 name conflict (daemon created nothing) \u2192 refuse; existing agent_ownership row (a concurrent creation won the name) \u2192 refuse; trinity.created label older than the attempt's floor, missing, or unparseable \u2192 refuse; missing floor \u2192 refuse; DB or Docker lookup error \u2192 refuse. Also: Redis cleared when the container is provably gone (removed or never created) and NOT when removal failed (the slot ZSET isn't idle); every failure swallowed so the original creation error is what the caller reports; and an AST guard that create_agent_internal actually AWAITS the reclaim, so the unit tests can't stay green while nothing calls it. Also pins the never-raises contract under a stubbed docker module: the first version used isinstance(exc, docker.errors.APIError), which raises TypeError wherever docker is a test double and replaced the real creation error with an unrelated one (caught by the full suite via test_fork_to_own / test_1484); the 409 check is duck-typed on .response.status_code now." + "description": "Failed-creation container reclaim (ent#313): a create that fails after containers.run must leave no orphan container, no unreclaimable volume and no name-keyed Redis state. Covers both arrival shapes — handle in hand (later step raised) and NO handle (the reported 60s Docker read timeout, where the daemon created the container and the client never got it, so it is re-derived by name). Pins every fail-closed gate on that re-derived path, because a name resolves to another install's live agent on a shared Docker daemon: 409 name conflict (daemon created nothing) → refuse; existing agent_ownership row (a concurrent creation won the name) → refuse; trinity.created label older than the attempt's floor, missing, or unparseable → refuse; missing floor → refuse; DB or Docker lookup error → refuse. Also: Redis cleared when the container is provably gone (removed or never created) and NOT when removal failed (the slot ZSET isn't idle); every failure swallowed so the original creation error is what the caller reports; and an AST guard that create_agent_internal actually AWAITS the reclaim, so the unit tests can't stay green while nothing calls it. Also pins the never-raises contract under a stubbed docker module: the first version used isinstance(exc, docker.errors.APIError), which raises TypeError wherever docker is a test double and replaced the real creation error with an unrelated one (caught by the full suite via test_fork_to_own / test_1484); the 409 check is duck-typed on .response.status_code now." }, { "file": "unit/test_1951_slack_inbound_media.py", @@ -1500,7 +1500,7 @@ "unit", "security" ], - "description": "Slack inbound file-download SSRF gate (#1951) \u2014 the third channel to get the #1932 two-tier treatment. download_file previously fetched url_private_download with no host allowlist, no scheme check and follow_redirects=True. Covers: the legitimate files.slack.com download still succeeds (the #1932 lesson \u2014 an allowlist that rejects the real host reads as 'allowlist present' while being 100% broken); a validated CDN redirect is followed WITHOUT the bot token; hop-1 refusals for unrelated/lookalike/apex-lookalike hosts, the dotless-suffix bypass (evil-slack-files.com), plaintext http, file://, loopback and a platform-network address; off-domain and downgrade redirects refused after exactly one hop; cloud metadata (169.254.169.254) as a redirect target; bounded redirect budget; refusal logged at ERROR not WARNING; source tier proven narrower than the redirect tier; and a static backstop that follow_redirects=True never returns. EVERY negative test asserts the recorded call log, not `is None` \u2014 download_file returns None on every failure path and its bare except swallows AssertionError, so a return-value assertion passes against a completely unpatched seam." + "description": "Slack inbound file-download SSRF gate (#1951) — the third channel to get the #1932 two-tier treatment. download_file previously fetched url_private_download with no host allowlist, no scheme check and follow_redirects=True. Covers: the legitimate files.slack.com download still succeeds (the #1932 lesson — an allowlist that rejects the real host reads as 'allowlist present' while being 100% broken); a validated CDN redirect is followed WITHOUT the bot token; hop-1 refusals for unrelated/lookalike/apex-lookalike hosts, the dotless-suffix bypass (evil-slack-files.com), plaintext http, file://, loopback and a platform-network address; off-domain and downgrade redirects refused after exactly one hop; cloud metadata (169.254.169.254) as a redirect target; bounded redirect budget; refusal logged at ERROR not WARNING; source tier proven narrower than the redirect tier; and a static backstop that follow_redirects=True never returns. EVERY negative test asserts the recorded call log, not `is None` — download_file returns None on every failure path and its bare except swallows AssertionError, so a return-value assertion passes against a completely unpatched seam." }, { "file": "unit/test_1917_stack_trace_exposure.py", @@ -1511,7 +1511,7 @@ "unit", "security" ], - "description": "py/stack-trace-exposure leak guards for the ops / system-agent siblings PR #1912 left untouched (#1917), including open CodeQL alert #231. Each test drives the REAL router function with a sentinel planted in the exception message and asserts both halves: the sentinel is absent from the response AND the exception class name is present (a response that dropped the error entirely would pass a sentinel-only check while making the failure undiagnosable). Covers stop_fleet per-agent, _stop_agent_container (feeds emergency_stop), fleet health (previously str(e)[:50] \u2014 truncation bounds a leak, it does not remove one, and docker/httpx messages put the host FIRST), ops costs (the OTel collector URL), and system-agent health. Plus a static ban on str(e) in ops.py/system_agent.py and a window check on agents.py's three lifecycle details. All 8 fail against pre-fix code \u2014 verified, because the fleet-health test initially patched a get_agent_context_info that does not exist (raising=False made it silent), so it passed against the unpatched router until the pre-fix run exposed it; it now patches get_agent_client and asserts the probe ran." + "description": "py/stack-trace-exposure leak guards for the ops / system-agent siblings PR #1912 left untouched (#1917), including open CodeQL alert #231. Each test drives the REAL router function with a sentinel planted in the exception message and asserts both halves: the sentinel is absent from the response AND the exception class name is present (a response that dropped the error entirely would pass a sentinel-only check while making the failure undiagnosable). Covers stop_fleet per-agent, _stop_agent_container (feeds emergency_stop), fleet health (previously str(e)[:50] — truncation bounds a leak, it does not remove one, and docker/httpx messages put the host FIRST), ops costs (the OTel collector URL), and system-agent health. Plus a static ban on str(e) in ops.py/system_agent.py and a window check on agents.py's three lifecycle details. All 8 fail against pre-fix code — verified, because the fleet-health test initially patched a get_agent_context_info that does not exist (raising=False made it silent), so it passed against the unpatched router until the pre-fix run exposed it; it now patches get_agent_client and asserts the probe ran." }, { "file": "unit/test_ent314_hardened_yaml.py", @@ -1522,7 +1522,7 @@ "unit", "security" ], - "description": "Hardened parsing of author-controlled YAML (ent#314). template.yaml was on bare yaml.safe_load, leaving alias amplification (measured 416 B -> 110 MB at json.dumps time, parse itself 0.0011 s, so an input-size cap cannot close it) and silent last-wins duplicate keys, which let a template show one credentials: block to a human and declare another to Trinity. Reachable by any creator-role user via a public repo (ent#123 tokenless) since _build_template runs unfenced inside get_all_templates(). Covers: the fixture itself still amplifies under bare safe_load (so the suite cannot pass vacuously); level 4/5/6 bombs rejected under both policies; a small honest anchor STILL parses under BUDGET (the #1932 lesson \u2014 a guard that rejects the legitimate document is an outage that reads as hardened); REJECT refuses one alias and also gates at the scanner (skill_packaging's copy had both hooks); the issue's exact duplicate-credentials case and a nested duplicate; size cap; reject-not-truncate; ManifestError still a subclass so routers/systems.py's named 400 is unchanged and the manifest's published codes survive; and two consolidation guards \u2014 no module may grow a fourth SafeLoader subclass, and all four author-controlled readers must call the shared loader with no bare safe_load left. The four product-level guards fail against pre-fix code." + "description": "Hardened parsing of author-controlled YAML (ent#314). template.yaml was on bare yaml.safe_load, leaving alias amplification (measured 416 B -> 110 MB at json.dumps time, parse itself 0.0011 s, so an input-size cap cannot close it) and silent last-wins duplicate keys, which let a template show one credentials: block to a human and declare another to Trinity. Reachable by any creator-role user via a public repo (ent#123 tokenless) since _build_template runs unfenced inside get_all_templates(). Covers: the fixture itself still amplifies under bare safe_load (so the suite cannot pass vacuously); level 4/5/6 bombs rejected under both policies; a small honest anchor STILL parses under BUDGET (the #1932 lesson — a guard that rejects the legitimate document is an outage that reads as hardened); REJECT refuses one alias and also gates at the scanner (skill_packaging's copy had both hooks); the issue's exact duplicate-credentials case and a nested duplicate; size cap; reject-not-truncate; ManifestError still a subclass so routers/systems.py's named 400 is unchanged and the manifest's published codes survive; and two consolidation guards — no module may grow a fourth SafeLoader subclass, and all four author-controlled readers must call the shared loader with no bare safe_load left. The four product-level guards fail against pre-fix code." }, { "file": "unit/test_1969_lock_denied_tick_audit.py", @@ -1776,7 +1776,7 @@ "reliability", "scheduling" ], - "description": "TZ-capability packaging parity (#1823), #1891-shaped static guard. Both tz-resolving images (backend, scheduler) install `tzdata-legacy` AND declare the `tzdata` wheel; tests/requirements-test.txt declares it too (CI must not be MORE capable than production \u2014 that inversion kept this bug green on CI and red in the image for months); the base image's exemption is asserted against the reason that holds (its tree registers no CronTrigger) rather than inherited; and both images pin the SAME APScheduler exactly, since the #1472 contract ('reject exactly what the scheduler would') is only true while validator and executor build the same CronTrigger \u2014 a floor on either side does not merely risk drift, it produces it, as `>=3.10.0,<4.0.0` resolves to whatever is newest at build time. Stale-path guard. Comments are stripped before matching, so the fix's own explanatory comments cannot make the guard vacuous. Plus one runtime assertion resolving `Europe/Kiev` \u2014 the one that goes red INSIDE a mis-packaged image. Pure stdlib, no Docker." + "description": "TZ-capability packaging parity (#1823), #1891-shaped static guard. Both tz-resolving images (backend, scheduler) install `tzdata-legacy` AND declare the `tzdata` wheel; tests/requirements-test.txt declares it too (CI must not be MORE capable than production — that inversion kept this bug green on CI and red in the image for months); the base image's exemption is asserted against the reason that holds (its tree registers no CronTrigger) rather than inherited; and both images pin the SAME APScheduler exactly, since the #1472 contract ('reject exactly what the scheduler would') is only true while validator and executor build the same CronTrigger — a floor on either side does not merely risk drift, it produces it, as `>=3.10.0,<4.0.0` resolves to whatever is newest at build time. Stale-path guard. Comments are stripped before matching, so the fix's own explanatory comments cannot make the guard vacuous. Plus one runtime assertion resolving `Europe/Kiev` — the one that goes red INSIDE a mis-packaged image. Pure stdlib, no Docker." }, { "file": "unit/test_1823_scheduler_permanent_classification.py", @@ -1801,7 +1801,7 @@ "api", "scheduling" ], - "description": "`PUT /api/agents/{name}/schedules/{id}` timezone gate (#1823 D4 \u2014 the silent persist that was quieter and worse than the create path's 500: the alias was written to the row and the failure deferred to a fire time that never came). Every case asserts the WRITE did not happen, not just the status code. Non-admin caller. Real-input (`Not/AZone`), fault-injected unresolvable-but-pytz-known, and a resolvable legacy alias going through with no injection. Route handler driven directly, no Docker." + "description": "`PUT /api/agents/{name}/schedules/{id}` timezone gate (#1823 D4 — the silent persist that was quieter and worse than the create path's 500: the alias was written to the row and the failure deferred to a fire time that never came). Every case asserts the WRITE did not happen, not just the status code. Non-admin caller. Real-input (`Not/AZone`), fault-injected unresolvable-but-pytz-known, and a resolvable legacy alias going through with no injection. Route handler driven directly, no Docker." }, { "file": "unit/test_1823_cron_only_put_effective_tz.py", @@ -1813,7 +1813,7 @@ "api", "scheduling" ], - "description": "Cron-only `PUT` validates against the row's OWN timezone instead of a hardcoded \"UTC\" (#1823) \u2014 the deliberate 200->400 for a row already storing an unregisterable zone. Pins assert on the ARGUMENT handed to the validator, since on a tz-complete runtime every zone validates identically and a verdict-based test would pass against the old literal. The injected failure misses exactly ONE zone for the same reason. Covers incoming-tz precedence, the NULL-stored-tz UTC fallback, the healthy path staying 200, and that a bad cron is still rejected. No Docker." + "description": "Cron-only `PUT` validates against the row's OWN timezone instead of a hardcoded \"UTC\" (#1823) — the deliberate 200->400 for a row already storing an unregisterable zone. Pins assert on the ARGUMENT handed to the validator, since on a tz-complete runtime every zone validates identically and a verdict-based test would pass against the old literal. The injected failure misses exactly ONE zone for the same reason. Covers incoming-tz precedence, the NULL-stored-tz UTC fallback, the healthy path staying 200, and that a bad cron is still rejected. No Docker." }, { "file": "unit/test_ent14_registry_document.py", @@ -1899,7 +1899,7 @@ "ssrf", "templates" ], - "description": "SSRF gate on the template-registry URL (ent#14 S3): RFC 6598 shared address space (100.64.0.0/10) is reported by Python's ipaddress as neither is_private nor is_reserved, so the predicate stack admitted it \u2014 refused now, with both /10 boundaries held public so a legitimate registry is not blackholed, and one CGNAT record among public ones enough to refuse (every resolved record is checked). Also pins the neighbouring internal ranges, IPv4-mapped IPv6, the HTTPS-only and no-userinfo rules, and refusal (not pass-through) of an unresolvable host. DNS is stubbed \u2014 the property under test is the predicate, not the resolver. Also pins WHY the sibling validate_skills_library_url has no CGNAT clause and must not grow one as a consistency fix: it is allowlisted by HOST, so the operator never chooses a destination and the resolved address is not an operator-controlled input. Four non-allowlisted hosts (incl. the suffix lookalike github.com.evil.com and the real-but-unlisted raw.githubusercontent.com) are refused while resolving to a PUBLIC address, so the refusal provably does not come from the address predicates, plus the accept case so it is an allowlist and not a blanket refusal. Copying the clause across is harmless; deleting the registry one as redundant is not, and this is where that turns from a judgement call into a failure. Verified red: removing the CGNAT clause fails 5 of 33, widening the skills-library allowlist fails 4." + "description": "SSRF gate on the template-registry URL (ent#14 S3): RFC 6598 shared address space (100.64.0.0/10) is reported by Python's ipaddress as neither is_private nor is_reserved, so the predicate stack admitted it — refused now, with both /10 boundaries held public so a legitimate registry is not blackholed, and one CGNAT record among public ones enough to refuse (every resolved record is checked). Also pins the neighbouring internal ranges, IPv4-mapped IPv6, the HTTPS-only and no-userinfo rules, and refusal (not pass-through) of an unresolvable host. DNS is stubbed — the property under test is the predicate, not the resolver. Also pins WHY the sibling validate_skills_library_url has no CGNAT clause and must not grow one as a consistency fix: it is allowlisted by HOST, so the operator never chooses a destination and the resolved address is not an operator-controlled input. Four non-allowlisted hosts (incl. the suffix lookalike github.com.evil.com and the real-but-unlisted raw.githubusercontent.com) are refused while resolving to a PUBLIC address, so the refusal provably does not come from the address predicates, plus the accept case so it is an allowlist and not a blanket refusal. Copying the clause across is harmless; deleting the registry one as redundant is not, and this is where that turns from a judgement call into a failure. Verified red: removing the CGNAT clause fails 5 of 33, widening the skills-library allowlist fails 4." }, { "file": "unit/test_ent14_registry_env_packaging.py", @@ -1924,7 +1924,7 @@ "unit", "guard" ], - "description": "The .trinity/ authored-vs-runtime split survives a backend Push (#2070). Every Push runs `git rm --cached` over tracked-but-now-ignored files; the fleet list ignored `.trinity/` WHOLESALE, so template-committed content there was untracked and its deletion pushed, guarded only by a hardcoded two-entry pathspec that was wrong three times (brain-orb ent#76, setup.sh \u2014 swept live \u2014 and pre-check #2070, the SCHED-COND-001 hook the platform's own docs tell authors to commit). Silent by construction: only the index is touched, so the working tree keeps running and the failure lands one re-clone/recreate later as an agent with no hook, whose every cron tick then runs a full LLM turn. Runs the REAL `_build_gitignore_merge_command` + `_build_rm_cached_ignored_command` against REAL git repos in tmp (the defect is in git's own descend/negation semantics, which a mocked container cannot express). Asserts the SPLIT, never the current exemption strings: 8 authored paths survive, 9 runtime files stay untracked, a fresh clone still RUNS the hook, the superseded wholesale line is replaced (append-only would be inert \u2014 git never descends into a dir-form exclusion), user rules merely mentioning .trinity are preserved, the merge is idempotent, exemptions are derived from `_TRINITY_AUTHORED_PATHS`, and a newly-invented runtime file needs no action. Mutation-verified 4 ways (wholesale ignore restored 5F, hardcoded exemptions 1F, no stale-line migration 2F, pre-check dropped from the set 4F)." + "description": "The .trinity/ authored-vs-runtime split survives a backend Push (#2070). Every Push runs `git rm --cached` over tracked-but-now-ignored files; the fleet list ignored `.trinity/` WHOLESALE, so template-committed content there was untracked and its deletion pushed, guarded only by a hardcoded two-entry pathspec that was wrong three times (brain-orb ent#76, setup.sh — swept live — and pre-check #2070, the SCHED-COND-001 hook the platform's own docs tell authors to commit). Silent by construction: only the index is touched, so the working tree keeps running and the failure lands one re-clone/recreate later as an agent with no hook, whose every cron tick then runs a full LLM turn. Runs the REAL `_build_gitignore_merge_command` + `_build_rm_cached_ignored_command` against REAL git repos in tmp (the defect is in git's own descend/negation semantics, which a mocked container cannot express). Asserts the SPLIT, never the current exemption strings: 8 authored paths survive, 9 runtime files stay untracked, a fresh clone still RUNS the hook, the superseded wholesale line is replaced (append-only would be inert — git never descends into a dir-form exclusion), user rules merely mentioning .trinity are preserved, the merge is idempotent, exemptions are derived from `_TRINITY_AUTHORED_PATHS`, and a newly-invented runtime file needs no action. Mutation-verified 4 ways (wholesale ignore restored 5F, hardcoded exemptions 1F, no stale-line migration 2F, pre-check dropped from the set 4F)." }, { "file": "unit/test_2080_harness_contract.py", @@ -1935,7 +1935,7 @@ "unit", "guard" ], - "description": "Guards the full-suite harness against silently rotting back (#2080). Every defect that issue fixed removed COVERAGE without failing anything: collection aborted before any test ran, tiers skipped themselves, and a sys.modules shadow swallowed each new src/backend/utils module. Asserts: tests/utils cannot come back (it shadowed the backend package on the pythonpath \u2014 adding safe_yaml.py broke ~1,000 tests with a ModuleNotFoundError that read like a product regression) and no test imports the helpers under the old name; no package directory has a non-identifier name (tests/git-sync's __init__.py put its conftest under the bare `conftest` key and collided with the root one, breaking collection of tests/integration and aborting the run before the unit tier); every test directory is named by a tier in run-full.sh; every tier passes -rs (without it the skip audit is blind) and a thread-method timeout; pytest exit code 5 (no tests collected) is a FAILURE; pyproject pins timeout_method=thread for hand-run pytest. Plus the audit's own honesty: each allowlist entry carries a written justification, an empty log dir refuses to certify, an unallowlisted skip fails and an allowlisted one passes." + "description": "Guards the full-suite harness against silently rotting back (#2080). Every defect that issue fixed removed COVERAGE without failing anything: collection aborted before any test ran, tiers skipped themselves, and a sys.modules shadow swallowed each new src/backend/utils module. Asserts: tests/utils cannot come back (it shadowed the backend package on the pythonpath — adding safe_yaml.py broke ~1,000 tests with a ModuleNotFoundError that read like a product regression) and no test imports the helpers under the old name; no package directory has a non-identifier name (tests/git-sync's __init__.py put its conftest under the bare `conftest` key and collided with the root one, breaking collection of tests/integration and aborting the run before the unit tier); every test directory is named by a tier in run-full.sh; every tier passes -rs (without it the skip audit is blind) and a thread-method timeout; pytest exit code 5 (no tests collected) is a FAILURE; pyproject pins timeout_method=thread for hand-run pytest. Plus the audit's own honesty: each allowlist entry carries a written justification, an empty log dir refuses to certify, an unallowlisted skip fails and an allowlisted one passes." }, { "file": "unit/test_ent356_portal_is_oss_core.py", @@ -1947,7 +1947,34 @@ "unit", "guard" ], - "description": "Pins that the workspace / client portal stays OSS core (ent#356). It shipped entitled \u2014 register_module('client_portal') + requires_entitlement on the router \u2014 so community builds mounted nothing and every endpoint 404'd, capping adoption at exactly the non-operator population the surface exists for. Guards the four ways that could silently revert: no entitlement machinery anywhere in the module (prose about the old gate is allowed, code is not); the router is mounted in main.py and never referenced from a register_enterprise-shaped function; no frontend isEntitled('client_portal') / requiresEntitlement route meta; and `client_portal.router` still resolves to the MODULE rather than the APIRouter object (the first __init__ did `from .router import router`, shadowing the submodule and breaking handler lookups in 13 tests). Plus the table contract: DDL declared in db/schema.py, BOTH migration tracks carry the adoption (Invariant #9), every CREATE TABLE/INDEX on both tracks is IF NOT EXISTS so an entitled install where the enterprise runner already made them is a no-op ('no data migration' is an acceptance criterion), the Alembic downgrade does not drop client conversation history it never created, and the historical `enterprise_` prefix is retained because renaming would be that forbidden data migration." + "description": "Pins that the workspace / client portal stays OSS core (ent#356). It shipped entitled — register_module('client_portal') + requires_entitlement on the router — so community builds mounted nothing and every endpoint 404'd, capping adoption at exactly the non-operator population the surface exists for. Guards the four ways that could silently revert: no entitlement machinery anywhere in the module (prose about the old gate is allowed, code is not); the router is mounted in main.py and never referenced from a register_enterprise-shaped function; no frontend isEntitled('client_portal') / requiresEntitlement route meta; and `client_portal.router` still resolves to the MODULE rather than the APIRouter object (the first __init__ did `from .router import router`, shadowing the submodule and breaking handler lookups in 13 tests). Plus the table contract: DDL declared in db/schema.py, BOTH migration tracks carry the adoption (Invariant #9), every CREATE TABLE/INDEX on both tracks is IF NOT EXISTS so an entitled install where the enterprise runner already made them is a no-op ('no data migration' is an acceptance criterion), the Alembic downgrade does not drop client conversation history it never created, and the historical `enterprise_` prefix is retained because renaming would be that forbidden data migration." + }, + { + "file": "unit/test_audit_chain_edges.py", + "feature": "#2015", + "added": "2026-08-05", + "categories": [ + "backend", + "unit", + "security", + "audit", + "edge-cases" + ], + "description": "Edge-case analysis of the audit hash chain as merged by #1985. Covers what the chain can and cannot DETECT (a mutated hashed field, a deleted middle row and a reordered pair are all caught; fields outside _compute_hash's subset - source/actor_email/actor_ip/endpoint/mcp_key_id - are silently mutable with the chain still reporting verified, and tail truncation is undetectable because verification is over a caller-supplied range), _compute_hash's real input domain (details as str vs dict must hash identically or every entry fails verification, key order in a details string, unparseable details hitting the except branch, non-ASCII, missing optional fields, and the four fields read with [] raising KeyError rather than hashing a hole), and range boundaries (inverted/absurd ranges degrade to empty_range, a single-row range never checks a link because of the i>0 guard, counts add up to total_in_range). Carries the xfail(strict) for #2015: enabling the hash chain is in-memory only, so a restart silently disables it, plus a static pin that the enable route persists nothing. Uses asyncio.run, not get_event_loop - the sibling #1984 suite closes the loop, so the latter passes standalone and fails on collection order." + }, + { + "file": "unit/test_pat_propagation_properties.py", + "feature": "#2016", + "added": "2026-08-05", + "categories": [ + "backend", + "unit", + "security", + "credentials", + "property-based", + "edge-cases" + ], + "description": "Round-trip property analysis of GITHUB_PAT propagation as merged by #1979. The contract is stated once as a Hypothesis property - after a rotation the agent reads back exactly the new token under all three key names (GITHUB_PAT/GH_TOKEN/GITHUB_TOKEN) - with the oracle being a byte-faithful copy of the agent-server's own last-wins .env parser, because what the agent sees is the only definition of success for a credential rotation. Plus idempotence, unrelated keys surviving, and the old token not surviving anywhere as text. Deterministic boundaries that hold: empty file, no trailing newline, leading whitespace/tab, commented-out line, present-but-empty, lookalike and prefixed keys, CRLF, append-not-glued. Three xfail(strict) findings: a duplicated GITHUB_PAT line survives count=1 and wins under last-wins parsing so the agent keeps the revoked token while the rotation reports updated (#2016); a backslash in the token raises re.error because the new line is used as an re.sub REPLACEMENT (#2017); and the writer escapes an embedded quote while the reader never unescapes it (#2017, a .env parse-contract issue deliberately left byte-faithful by #1999)." } ] } diff --git a/tests/unit/test_audit_chain_edges.py b/tests/unit/test_audit_chain_edges.py new file mode 100644 index 000000000..0f06e6726 --- /dev/null +++ b/tests/unit/test_audit_chain_edges.py @@ -0,0 +1,344 @@ +"""Edge-case analysis of the audit hash chain (`/edge-cases`, 2026-08-05). + +Target: `services/platform_audit_service.PlatformAuditService.verify_chain`, +`_compute_hash` and `enable_hash_chain`, as merged on `dev` by #1985 (issue +#1984). + +#1984's own suite covers the tri-state verdict thoroughly. This file covers the +dimensions it does not: what the chain check can and cannot *detect* (deletion, +reordering, a tampered field the hasher doesn't read), what `_compute_hash` does +with hostile-but-reachable field values, and whether the control that produces +hashes in the first place survives the lifecycle it lives in. + +Cases marked `xfail(strict=True)` are REAL defects, not aspirational tests — +each one names the finding it pins. Per the skill's protocol, product code is +not changed here. +""" + +from __future__ import annotations + +import asyncio +import json +import sys +from pathlib import Path + +import pytest + +_REPO = Path(__file__).resolve().parents[2] +_BACKEND_STR = str(_REPO / "src" / "backend") +while _BACKEND_STR in sys.path: + sys.path.remove(_BACKEND_STR) +sys.path.insert(0, _BACKEND_STR) + +pytestmark = pytest.mark.unit + + +@pytest.fixture +def svc(): + try: + from services.platform_audit_service import platform_audit_service + except ImportError: # pragma: no cover — backend venv required + pytest.skip("backend venv required") + return platform_audit_service + + +@pytest.fixture +def hasher(svc): + """`_compute_hash` is a staticmethod on the service, not a module global.""" + return svc._compute_hash + + +@pytest.fixture +def mod(): + try: + import services.platform_audit_service as m + except ImportError: # pragma: no cover + pytest.skip("backend venv required") + return m + + +def _rows(mod, monkeypatch, rows): + monkeypatch.setattr(mod.db, "get_audit_entries_range", + lambda s, e: rows, raising=False) + + +def _row(id_: int, **over) -> dict: + row = { + "id": id_, + "event_id": f"evt-{id_}", + "event_type": "configuration", + "event_action": "settings_change", + "actor_id": "1", + "target_id": None, + "timestamp": f"2026-08-04T00:00:{id_:02d}Z", + "details": None, + "entry_hash": None, + "previous_hash": None, + } + row.update(over) + return row + + +def _chain(hasher, n: int, **over) -> list: + """`n` rows correctly hashed and linked, as `log()` would have written them.""" + out, prev = [], None + for i in range(1, n + 1): + row = _row(i, previous_hash=prev, **over) + row["entry_hash"] = hasher(row) + prev = row["entry_hash"] + out.append(row) + return out + + +def _verify(svc, start=1, end=100): + """`asyncio.run`, not `get_event_loop().run_until_complete`. + + The sibling #1984 suite uses `asyncio.run`, which closes the loop and + clears the current-loop slot — so a `get_event_loop()` here passes when + this file runs alone and raises "no current event loop" the moment the two + are collected together. Collection-order-dependent, i.e. green locally and + red in CI. + """ + return asyncio.run(svc.verify_chain(start, end)) + + +# --------------------------------------------------------------------------- +# What the chain can detect — the reason it exists +# --------------------------------------------------------------------------- + +class TestTamperDetection: + + def test_an_intact_chain_verifies(self, svc, mod, hasher, monkeypatch): + _rows(mod, monkeypatch, _chain(hasher, 5)) + r = _verify(svc) + assert (r["valid"], r["status"], r["checked"]) == (True, "verified", 5) + + def test_a_mutated_field_is_detected(self, svc, mod, hasher, monkeypatch): + chain = _chain(hasher, 5) + chain[2]["event_action"] = "settings_change_TAMPERED" + _rows(mod, monkeypatch, chain) + r = _verify(svc) + assert r["valid"] is False and r["first_invalid_id"] == 3 + + def test_a_deleted_middle_row_is_detected(self, svc, mod, hasher, monkeypatch): + """Deletion is the attack the LINK check exists for: each surviving + hash is still self-consistent, so only `previous_hash` catches it.""" + chain = _chain(hasher, 5) + del chain[2] + _rows(mod, monkeypatch, chain) + r = _verify(svc) + assert r["valid"] is False, "a deleted row left the chain reporting intact" + + def test_reordered_rows_are_detected(self, svc, mod, hasher, monkeypatch): + chain = _chain(hasher, 5) + chain[1], chain[2] = chain[2], chain[1] + _rows(mod, monkeypatch, chain) + r = _verify(svc) + assert r["valid"] is False + + @pytest.mark.parametrize("field", ["source", "actor_email", "endpoint", + "actor_ip", "mcp_key_id"]) + def test_fields_outside_the_hashed_subset_are_silently_mutable( + self, svc, mod, hasher, monkeypatch, field + ): + """Documents the boundary rather than asserting a bug. + + `_compute_hash` covers event_id/type/action/actor_id/target_id/ + timestamp/details/previous_hash. Everything else on the row — including + `actor_ip` and `actor_email`, which an incident responder would read as + attributable evidence — can be edited with the chain still reporting + `verified`. Worth knowing before citing a green tick as proof of who + did something. + """ + chain = _chain(hasher, 3) + chain[1][field] = "rewritten-after-the-fact" + _rows(mod, monkeypatch, chain) + assert _verify(svc)["valid"] is True + + def test_truncation_at_the_tail_is_not_detectable(self, svc, mod, hasher, monkeypatch): + """Also a boundary, not a bug: verification is over a caller-supplied + range, so dropping the newest rows leaves a shorter intact chain. The + DB trigger (`audit_log_no_delete`) is what defends this, not the hash.""" + _rows(mod, monkeypatch, _chain(hasher, 5)[:3]) + assert _verify(svc)["valid"] is True + + +# --------------------------------------------------------------------------- +# _compute_hash — inputs it will actually meet +# --------------------------------------------------------------------------- + +class TestComputeHash: + + def test_details_str_and_dict_hash_identically(self, mod, hasher): + """The write path stores `details` as a JSON string and the read path + returns a dict; the hash has to be stable across that round-trip or + every entry fails verification.""" + d = {"b": 1, "a": [1, 2, {"z": None}]} + as_dict = _row(1, details=d) + as_str = _row(1, details=json.dumps(d)) + assert hasher(as_dict) == hasher(as_str) + + def test_key_order_in_a_details_string_does_not_change_the_hash(self, mod, hasher): + a = _row(1, details='{"x": 1, "y": 2}') + b = _row(1, details='{"y": 2, "x": 1}') + assert hasher(a) == hasher(b) + + def test_unparseable_details_still_hashes(self, mod, hasher): + """The `except (TypeError, ValueError): pass` branch — the value stays + a string and must not raise.""" + assert hasher(_row(1, details="{not json")) + + def test_non_ascii_details_hash_stably(self, mod, hasher): + row = _row(1, details={"note": "café ✓ 日本語"}) + assert hasher(row) == hasher(dict(row)) + + def test_a_missing_optional_field_is_not_a_crash(self, mod, hasher): + row = _row(1) + row.pop("actor_id") + row.pop("target_id") + assert hasher(row) + + @pytest.mark.parametrize("missing", ["event_id", "event_type", + "event_action", "timestamp"]) + def test_required_fields_raise_rather_than_hash_a_hole(self, mod, hasher, missing): + """These are read with `[]`, not `.get()`. A row missing one is a + programming error and should surface as one, not hash to a value that + silently differs from what was written.""" + row = _row(1) + row.pop(missing) + with pytest.raises(KeyError): + hasher(row) + + def test_details_none_and_details_null_string_collide(self, mod, hasher): + """`details=None` and `details="null"` both normalize to JSON `null`, + so they hash identically. Harmless (both mean 'no details') and + recorded so a future 'fix' doesn't treat it as a defect.""" + assert hasher(_row(1, details=None)) == \ + hasher(_row(1, details="null")) + + +# --------------------------------------------------------------------------- +# The control that produces the hashes +# --------------------------------------------------------------------------- + +class TestHashChainLifecycle: + + def test_enabling_is_reflected_in_the_verdict(self, svc, mod, hasher, monkeypatch): + # Forced through BOTH seams so this reads the same answer before and + # after #2026: today the verdict is computed from the private + # `_hash_chain_enabled`; once the flag moves to `system_settings` it is + # computed from the `hash_chain_enabled` property, and a test that + # forces only the private attribute fails on the merged tree while + # testing nothing about the flag. Both are `raising=False`, so whichever + # seam does not exist in the tree under test is simply unused. + monkeypatch.setattr(svc, "_hash_chain_enabled", True, raising=False) + monkeypatch.setattr( + type(svc), "hash_chain_enabled", property(lambda self: True), raising=False + ) + _rows(mod, monkeypatch, _chain(hasher, 2)) + assert _verify(svc)["hash_chain_enabled"] is True + + def test_enabling_the_hash_chain_survives_a_restart(self, mod, hasher): + """Regression test for #2015 (fixed by #2026). + + Before the fix, `enable_hash_chain(True)` set `self._hash_chain_enabled` + and wrote nothing. A fresh process — every deploy, every config change, + the documented post-restart re-login — started `False`, and nothing told + the operator that hashing had stopped. #2026 moves the flag to + `system_settings`, so a second instance now reads it back. + + Modelled as "construct a second service instance", which is exactly what + the next process does. + """ + svc_a = mod.PlatformAuditService() + svc_a.enable_hash_chain(True) + + svc_b = mod.PlatformAuditService() # the process after a restart + + # The PUBLIC seam, not `_hash_chain_enabled`. The private attribute is + # what the fix (#2026) deletes when the flag moves to `system_settings`, + # and a `strict=True` xfail treats the resulting AttributeError as an + # expected failure exactly like the assertion failure it replaces — so + # the marker would go on reporting "BUG: ... in-memory only" against a + # codebase where the bug is dead, which is the opposite of what strict + # is for. Asserting the public seam makes it XPASS(strict) — loud — the + # moment the flag genuinely persists. + assert svc_b.hash_chain_enabled is True, ( + "hash chain silently reverted to disabled in a new process" + ) + + def test_the_enable_route_persists_durably(self): + """Pins the mechanism behind the test above, so the #2015 fix survives a + refactor: the flag must be written durably, not just set in memory. + + Checks BOTH ends of the delegation. #2026 puts the write in the + *service* (`db.set_setting(...)`) while leaving the router a thin + passthrough, so a router-only check would pass even if the service + write were later deleted. + + The service half is asserted over the AST, not the text, so a docstring + that merely *describes* persistence can't satisfy it — only a real call + can. + """ + import ast + import inspect + import textwrap + + src = (_REPO / "src" / "backend" / "routers" / "audit_log.py").read_text() + block = src[src.index("async def enable_hash_chain"):] + block = block[:block.index("\n@router") if "\n@router" in block else len(block)] + assert "platform_audit_service.enable_hash_chain" in block + + # ...and the setter the route delegates to, resolved through the import + # rather than a fixed filename, so moving the service doesn't silence it. + if _BACKEND_STR not in sys.path: + sys.path.insert(0, _BACKEND_STR) + from services.platform_audit_service import PlatformAuditService + + setter = ast.parse( + textwrap.dedent(inspect.getsource(PlatformAuditService.enable_hash_chain)) + ) + persisted = [ + node for node in ast.walk(setter) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr in {"set_setting", "set_system_setting"} + ] + assert persisted, ( + "`enable_hash_chain` no longer persists the flag — #2015 has " + "regressed: a restart will silently disable the hash chain" + ) + + +# --------------------------------------------------------------------------- +# Range/argument boundaries +# --------------------------------------------------------------------------- + +class TestRangeBoundaries: + + @pytest.mark.parametrize("start,end", [(0, 0), (5, 1), (-1, -1), + (1, 10**12)]) + def test_odd_ranges_degrade_rather_than_raise(self, svc, mod, hasher, monkeypatch, + start, end): + """The accessor decides what an inverted or absurd range returns; the + verifier must not add a crash on top of an empty result.""" + _rows(mod, monkeypatch, []) + r = _verify(svc, start, end) + assert r["valid"] is None and r["status"] == "empty_range" + + def test_a_single_row_range_never_checks_a_link(self, svc, mod, hasher, monkeypatch): + """`i > 0` means the first row in ANY range has its `previous_hash` + unchecked. For a one-row range that is the whole verdict — self-hash + only. Documented so a caller doesn't read `verified` on a 1-row range + as 'linked to the row before it'.""" + chain = _chain(hasher, 3) + _rows(mod, monkeypatch, [chain[2]]) + r = _verify(svc) + assert (r["valid"], r["checked"]) == (True, 1) + + def test_counts_add_up(self, svc, mod, hasher, monkeypatch): + chain = _chain(hasher, 3) + [_row(4), _row(5)] + _rows(mod, monkeypatch, chain) + r = _verify(svc) + assert r["checked"] + r["skipped_unhashed"] == r["total_in_range"] == 5 + assert r["status"] == "verified_partial" diff --git a/tests/unit/test_pat_propagation_properties.py b/tests/unit/test_pat_propagation_properties.py new file mode 100644 index 000000000..fb1813f47 --- /dev/null +++ b/tests/unit/test_pat_propagation_properties.py @@ -0,0 +1,234 @@ +"""Edge-case + property analysis of PAT propagation (`/edge-cases`, 2026-08-05). + +Target: `services/github_pat_propagation_service._patch_env_github_pat` / +`_format_pat_line` / `_env_has_github_pat`, as merged on `dev` by #1979 +(issue #1967). + +#1967's suite covers eligibility, the remote rewrite, and per-agent failure +isolation. What it does not cover is the *text transform itself*: this is a +regex substitution that writes a credential into a file another process parses, +so the honest question is a round-trip one — **after patching, does the agent +read back exactly the token we rotated to?** + +That is stated once as a Hypothesis property and pinned as explicit cases for +the two inputs where it does not hold. The oracle is the agent's own `.env` +reader, copied here from +`docker/base-image/agent_server/services/execution_env.parse_env_file` — the +same last-wins, one-quote-pair semantics, because "what the agent sees" is the +only definition of success that matters for a credential rotation. + +Cases marked `xfail(strict=True)` are real defects; product code is unchanged +per the skill's protocol. +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +_REPO = Path(__file__).resolve().parents[2] +_BACKEND_STR = str(_REPO / "src" / "backend") +while _BACKEND_STR in sys.path: + sys.path.remove(_BACKEND_STR) +sys.path.insert(0, _BACKEND_STR) + +pytestmark = pytest.mark.unit + + +@pytest.fixture(scope="module") +def svc(): + try: + import services.github_pat_propagation_service as m + except ImportError: # pragma: no cover — backend venv required + pytest.skip("backend venv required") + return m + + +# --------------------------------------------------------------------------- +# The oracle: the agent's own .env reader +# --------------------------------------------------------------------------- + +_ENV_LINE_KEYS = ("GITHUB_PAT", "GH_TOKEN", "GITHUB_TOKEN") + + +def agent_reads(env_content: str) -> dict: + """Byte-faithful copy of the agent-server `.env` parser (last-wins). + + Deliberately a copy and not an import: the agent server ships in its own + image and the backend cannot import from it. Kept small enough to audit + against the original by eye. + """ + out = {} + for line in env_content.splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + key = key.strip() + if not key: + continue + out[key] = value.strip().strip('"').strip("'") + return out + + +# A GitHub PAT's real alphabet (`ghp_…`, `github_pat_…`) — the tokens this code +# will actually be handed. +REAL_PATS = st.from_regex(r"\A(ghp_[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9_]{20,60})\Z") + +# Plausible surrounding .env content, excluding the keys under test so the +# fixture cannot pre-seed the very lines the property is about. +ENV_NOISE = st.lists( + st.tuples( + st.from_regex(r"\A[A-Z][A-Z0-9_]{0,12}\Z").filter( + lambda k: k not in _ENV_LINE_KEYS + ), + st.text(alphabet=st.characters(blacklist_categories=("Cs", "Cc")), max_size=20), + ), + max_size=6, +).map(lambda pairs: "".join(f'{k}="{v}"\n' for k, v in pairs)) + + +# --------------------------------------------------------------------------- +# The property +# --------------------------------------------------------------------------- + +class TestRoundTrip: + + @settings(max_examples=200, deadline=None, + suppress_health_check=[HealthCheck.function_scoped_fixture]) + @given(pat=REAL_PATS, noise=ENV_NOISE, has_existing=st.booleans()) + def test_the_agent_reads_back_exactly_the_rotated_token( + self, svc, pat, noise, has_existing + ): + """The whole point of the feature, as one property: whatever the file + looked like, after a rotation the agent must read the NEW token under + all three key names.""" + env = noise + ('GITHUB_PAT="ghp_' + "o" * 36 + '"\n' if has_existing else "") + patched = svc._patch_env_github_pat(env, pat) + seen = agent_reads(patched) + for key in _ENV_LINE_KEYS: + assert seen.get(key) == pat, f"{key} reads back as {seen.get(key)!r}" + + @settings(max_examples=100, deadline=None, + suppress_health_check=[HealthCheck.function_scoped_fixture]) + @given(pat=REAL_PATS, noise=ENV_NOISE) + def test_patching_is_idempotent(self, svc, pat, noise): + once = svc._patch_env_github_pat(noise, pat) + assert svc._patch_env_github_pat(once, pat) == once + + @settings(max_examples=100, deadline=None, + suppress_health_check=[HealthCheck.function_scoped_fixture]) + @given(pat=REAL_PATS, noise=ENV_NOISE) + def test_unrelated_keys_survive(self, svc, pat, noise): + before = agent_reads(noise) + after = agent_reads(svc._patch_env_github_pat(noise, pat)) + for k, v in before.items(): + assert after.get(k) == v, f"rotation clobbered unrelated key {k}" + + @settings(max_examples=100, deadline=None, + suppress_health_check=[HealthCheck.function_scoped_fixture]) + @given(pat=REAL_PATS, noise=ENV_NOISE) + def test_no_token_is_left_behind_anywhere_in_the_file(self, svc, pat, noise): + """Stronger than the read-back: the OLD token must not survive as text + either, or a `.env` sourced by a shell (or grepped by a human) still + yields the revoked credential.""" + old = "ghp_" + "o" * 36 + patched = svc._patch_env_github_pat(f'GITHUB_PAT="{old}"\n' + noise, pat) + assert old not in patched + + +# --------------------------------------------------------------------------- +# Where the round-trip does not hold +# --------------------------------------------------------------------------- + +class TestKnownGaps: + + def test_a_duplicated_pat_line_still_rotates(self, svc): + """Finding 2 — FIXED by #2016 (PR #2025), marker retired. + + This carried `xfail(strict=True)` while the bug was live: `count=1` + replaced only the FIRST `GITHUB_PAT` line, and the agent's `.env` parser + is last-wins, so a duplicated line left the agent authenticating with the + REVOKED token while the rotation reported `updated`. + + `31ba8d98` levels every occurrence, so the marker did its job and flipped + to XPASS(strict) — which is a FAILURE, and is why CI went red here rather + than quietly reporting a bug that no longer exists. Retired to a plain + regression test: it now guards the fix instead of the defect. + """ + env = ('GITHUB_PAT="ghp_old"\n' + 'SOMETHING=1\n' + 'GITHUB_PAT="ghp_old"\n') + patched = svc._patch_env_github_pat(env, "ghp_new") + assert agent_reads(patched)["GITHUB_PAT"] == "ghp_new", ( + "the agent still reads the old token" + ) + + @pytest.mark.parametrize("pat", [r"tok\1", r"tok\g<1>", r"tok\slash"]) + @pytest.mark.xfail( + strict=True, + reason="BUG: the new line is used as an `re.sub` REPLACEMENT, so a " + "backslash in the token is parsed as a group reference and " + "raises re.error mid-rotation. See /edge-cases report " + "2026-08-05, finding 3.", + ) + def test_a_backslash_in_the_token_does_not_raise(self, svc, pat): + svc._patch_env_github_pat('GITHUB_PAT="old"\n', pat) + + @pytest.mark.xfail( + strict=True, + reason="Writer/reader asymmetry: `_format_pat_line` escapes `\"` but " + "the agent's .env parser never unescapes it. Belongs to the " + ".env parse contract, not to #1979. See /edge-cases report " + "2026-08-05, finding 4.", + ) + def test_a_quote_in_the_token_round_trips(self, svc): + pat = 'ghp_a"b' + patched = svc._patch_env_github_pat('GITHUB_PAT="old"\n', pat) + assert agent_reads(patched)["GITHUB_PAT"] == pat + + +# --------------------------------------------------------------------------- +# Deterministic boundary cases that DO hold (regression value) +# --------------------------------------------------------------------------- + +class TestBoundaries: + + @pytest.mark.parametrize("env,label", [ + ("", "empty file"), + ("\n", "just a newline"), + ("NO_TRAILING_NEWLINE=1", "no trailing newline"), + (" GITHUB_PAT=\"indented\"\n", "leading whitespace"), + ("\tGITHUB_PAT=\"tabbed\"\n", "leading tab"), + ("# GITHUB_PAT=\"commented\"\n", "commented-out line"), + ("GITHUB_PAT=\n", "present but empty"), + ("GITHUB_PATX=\"lookalike\"\n", "lookalike key"), + ("MY_GITHUB_PAT=\"suffix\"\n", "key with a prefix"), + ("A=1\r\nGITHUB_PAT=\"crlf\"\r\n", "CRLF line endings"), + ]) + def test_the_agent_reads_the_new_token(self, svc, env, label): + patched = svc._patch_env_github_pat(env, "ghp_new") + assert agent_reads(patched).get("GITHUB_PAT") == "ghp_new", label + + def test_a_lookalike_key_is_not_clobbered(self, svc): + patched = svc._patch_env_github_pat('GITHUB_PATX="keep"\n', "ghp_new") + assert agent_reads(patched)["GITHUB_PATX"] == "keep" + + def test_a_commented_line_is_not_treated_as_a_pat(self, svc): + assert svc._env_has_github_pat('# GITHUB_PAT="x"\n') is False + + def test_an_exported_line_is_not_recognised(self, svc): + """`export GITHUB_PAT=…` reads as "no PAT", so on the global path such + an agent is skipped as `skipped_no_pat`. Documented, not asserted as a + bug: the eligibility rule is deliberately `.env`-line-shaped.""" + assert svc._env_has_github_pat('export GITHUB_PAT="x"\n') is False + + def test_appending_does_not_glue_onto_a_previous_line(self, svc): + patched = svc._patch_env_github_pat("FOO=1", "ghp_new") + assert "FOO=1GITHUB_PAT" not in patched + assert agent_reads(patched)["FOO"] == "1"