diff --git a/docs/memory/feature-flows/first-time-setup.md b/docs/memory/feature-flows/first-time-setup.md index 6a4f61f9f7..27a2eb495a 100644 --- a/docs/memory/feature-flows/first-time-setup.md +++ b/docs/memory/feature-flows/first-time-setup.md @@ -358,6 +358,11 @@ async def set_admin_password(data, request, background_tasks): # 6. Mark setup completed db.set_setting('setup_completed', 'true') + # 6b. Deploy the system agent (#3237) — its boot-time attempt ran before this + # admin existed and failed. Background task, never raises, scheduled + # before the first-run seed (ensure_first_run_seeded). + background_tasks.add_task(_deploy_system_agent_after_setup) + # 7. Operator intake (trinity-enterprise#38): only on affirmative consent, # scheduled as a BackgroundTask so it runs after the response if data.consent_updates: diff --git a/docs/memory/feature-flows/internal-system-agent.md b/docs/memory/feature-flows/internal-system-agent.md index 88b9dacdc3..9be2befccd 100644 --- a/docs/memory/feature-flows/internal-system-agent.md +++ b/docs/memory/feature-flows/internal-system-agent.md @@ -453,6 +453,8 @@ Backend Startup └──────────────────────────────────┘ ``` +**Second trigger — first-run `/setup` (#3237).** On a browser-claimed install (every one-click / marketplace path) no admin exists at boot, so the create branch above fails with `Admin user 'admin' not found`. `POST /api/setup/admin-password` therefore schedules `routers/setup.py::_deploy_system_agent_after_setup` as a background task once the admin row is written — before `ensure_first_run_seeded`, matching the boot order (the seeder hosts its failure alerts on `trinity-system`). It never raises (Starlette runs background tasks in sequence) and skips without a Docker client; `ensure_deployed` is idempotent, so the next boot is a no-op. + ### 2. Fleet Health Check Flow ``` @@ -1050,6 +1052,7 @@ ls -1 ~/reports/fleet/ | tail -1 | Date | Changes | |------|---------| +| 2026-10-06 | **#3237**: `/setup` deploys the system agent after creating the admin — the boot-time attempt runs before the admin exists on browser-claimed installs and was never retried. | | 2026-07-28 | **#1816 base-image adoption**: `ensure_deployed` is read-only when the container is running (3-state `check_base_image_state` → `base_image_state`, WARNING + an edge-triggered `base-image-stale-` operator alarm on `stale` only) and delegates to `start_agent_internal` when stopped, so the cold boundary adopts a rebuilt base image through the shared lifecycle. Creation converges on the recreate path's contract (`TRINITY_AGENT_AUTH_TOKEN`, `trinity.full-capabilities`) — the convergence invariant. AC2 is a **structural** gate in `start_agent_internal` (`is_system AND was_already_running` ⇒ `recreate_deferred="system_agent_running"`), independent of predicate count. `/restart` delegates; `/status` gains `base_image_state`. | | 2026-02-11 | Fixed reinitialize flow diagram - cleanup command now shows actual paths (`/home/developer/.claude`, `.trinity`, `content`, `plans`) instead of obsolete workspace reference | | 2026-01-27 | **Emergency Stop Prefix Filter**: Added `system_prefix` query parameter to `POST /api/ops/emergency-stop` (`routers/ops.py:607-696`). Allows targeting specific agents/schedules by name prefix. Schedule pausing respects prefix (line 638-639), agent stopping respects prefix (line 658-659). Enables safe testing with nonexistent prefix. | diff --git a/src/backend/routers/setup.py b/src/backend/routers/setup.py index 3f7dd9ea68..cacfb744d2 100644 --- a/src/backend/routers/setup.py +++ b/src/backend/routers/setup.py @@ -137,6 +137,31 @@ def _take_claim() -> Path: return taken +async def _deploy_system_agent_after_setup() -> None: + """Deploy `trinity-system` now that its owner exists (#3237). + + The boot-time attempt (`main.py::_init_docker_and_system_agent`) runs before + /setup has created the admin on every browser-claimed install, fails with + "Admin user 'admin' not found", and is never retried. This is that retry. + + Never raises: Starlette runs background tasks in sequence, so a raise here + would also skip the first-run seed and the operator intake. Imports are + lazy to keep this router's import isolation (see the setup tests). + """ + try: + from services.docker_service import docker_client + from services.system_agent_service import system_agent_service + + if not docker_client: + return + result = await system_agent_service.ensure_deployed() + logger.info(f"System agent after setup: {result['action']} - {result['message']}") + if result.get('status') == 'error': + logger.warning(f" Warning: System agent deployment issue - {result.get('message')}") + except Exception as e: + logger.error(f"Error deploying system agent after setup: {e}") + + @router.get("/status") async def get_setup_status(): """ @@ -317,6 +342,12 @@ async def set_admin_password( except OSError as e: logger.warning("Could not delete the setup claim file: %s", type(e).__name__) + # The system agent (#3237): its boot-time deploy ran before this admin + # existed and failed. Scheduled BEFORE the seed — the boot order, and the + # seeder hosts its failure alerts on `trinity-system`. Idempotent, so a + # later restart is a no-op. + background_tasks.add_task(_deploy_system_agent_after_setup) + # First-run seeding: the default Cornelius agent (ent#107) plus the default # system manifest (trinity-enterprise#124), sequenced under ONE persisted # freshness verdict. Now that the admin account exists, provision the bundled diff --git a/tests/registry.json b/tests/registry.json index aaf6ec0245..a0646d7e78 100644 --- a/tests/registry.json +++ b/tests/registry.json @@ -1,5 +1,16 @@ { "test_files": [ + { + "file": "unit/test_3237_system_agent_after_setup.py", + "feature": "#3237", + "added": "2026-10-06", + "categories": [ + "backend", + "setup", + "system-agent" + ], + "description": "First-run /setup deploys the system agent: a successful setup schedules _deploy_system_agent_after_setup before ensure_first_run_seeded, the deploy runs with the admin row already present, never raises, is skipped without a Docker client, and a refused setup schedules nothing." + }, { "file": "unit/test_ent761_outbound_control_oss.py", "feature": "trinity-enterprise#761", diff --git a/tests/unit/test_3237_system_agent_after_setup.py b/tests/unit/test_3237_system_agent_after_setup.py new file mode 100644 index 0000000000..d1bb863806 --- /dev/null +++ b/tests/unit/test_3237_system_agent_after_setup.py @@ -0,0 +1,157 @@ +"""#3237 — a fresh install that creates its admin through /setup gets its system agent. + +The only deploy attempt used to run at backend boot, BEFORE /setup had created +the admin, and failed with "Admin user 'admin' not found". Nothing retried it, +so `trinity-system` stayed absent until something restarted the backend — on +every one-click / marketplace path (DigitalOcean, AWS, the hosted script). + +Under test: + +* a successful setup schedules `system_agent_service.ensure_deployed()` as a + background task, and it runs AFTER the admin row exists; +* it is scheduled BEFORE first-run seeding — the boot order, and the seeder + hosts its failure alerts on `trinity-system`; +* the deploy task never raises (Starlette runs background tasks in sequence, so + a raise would also skip the seed and the intake) and is skipped without Docker; +* a refused setup schedules nothing. + +Import isolation: `routers.setup` is imported lazily (see +test_setup_operator_profile.py for why). +""" +import asyncio +import sys +import types + +import pytest +from fastapi import BackgroundTasks, HTTPException + +pytestmark = pytest.mark.unit + +PW = "Sup3rSecret!!" +HASH = "$2b$12$abcdefghijklmnopqrstuv" + + +def _setup(): + import routers.setup as m + return m + + +class FakeDB: + def __init__(self, users=None): + self.settings = {"setup_completed": "false"} + self.users = users or {} + + def get_user_by_username(self, username): + return self.users.get(username) + + def get_setting_value(self, key, default=None): + return self.settings.get(key, default) + + def set_setting(self, key, value): + self.settings[key] = value + + def update_user_password(self, username, hashed): + self.users.setdefault(username, {"username": username})["password"] = hashed + return True + + def update_user(self, username, updates): + self.users.setdefault(username, {"username": username}).update(updates) + return self.users[username] + + +class FakeSystemAgentService: + """Mirrors `_create_system_agent`'s precondition: the admin row must exist.""" + + def __init__(self, db, raises=None): + self.db, self.raises, self.calls = db, raises, [] + + async def ensure_deployed(self): + self.calls.append(self.db.get_user_by_username("admin") is not None) + if self.raises: + raise self.raises + return {"action": "created", "status": "running", "message": "System agent created and started"} + + +@pytest.fixture +def env(monkeypatch): + def _apply(users=None, docker=True, raises=None): + setup = _setup() + db = FakeDB(users) + svc = FakeSystemAgentService(db, raises) + monkeypatch.delenv("ADMIN_PASSWORD_SOURCE", raising=False) + monkeypatch.delenv("ADMIN_PASSWORD", raising=False) + monkeypatch.delenv("ADMIN_USERNAME", raising=False) + monkeypatch.setattr(setup, "db", db) + monkeypatch.setattr(setup, "validate_password_strength", lambda p: []) + monkeypatch.setattr(setup, "hash_password", lambda p: "hashed:" + p) + # The helper imports both lazily; stub the modules it reaches for. + monkeypatch.setitem( + sys.modules, "services.system_agent_service", + types.SimpleNamespace(system_agent_service=svc), + ) + monkeypatch.setitem( + sys.modules, "services.docker_service", + types.SimpleNamespace(docker_client=object() if docker else None), + ) + return db, svc + return _apply + + +def _post(bg): + setup = _setup() + data = setup.SetAdminPasswordRequest( + password=PW, confirm_password=PW, email="me@example.com", + ) + return asyncio.run(setup.set_admin_password(data, object(), bg)) + + +def _run(task): + result = task.func(*task.args, **task.kwargs) + if asyncio.iscoroutine(result): + asyncio.run(result) + + +def _names(bg): + return [t.func.__name__ for t in bg.tasks] + + +def test_setup_schedules_the_system_agent_deploy_before_seeding(env): + env() + bg = BackgroundTasks() + assert _post(bg)["success"] is True + names = _names(bg) + assert "_deploy_system_agent_after_setup" in names, ( + "/setup must deploy the system agent — the boot attempt ran before the admin existed" + ) + assert names.index("_deploy_system_agent_after_setup") < names.index("ensure_first_run_seeded"), ( + "boot order: the system agent before the seed, which hosts its alerts on trinity-system" + ) + + +def test_the_deploy_runs_after_the_admin_exists(env): + _, svc = env() + bg = BackgroundTasks() + _post(bg) + _run(next(t for t in bg.tasks if t.func.__name__ == "_deploy_system_agent_after_setup")) + assert svc.calls == [True], "ensure_deployed ran once, with the admin row already present" + + +def test_a_failed_deploy_never_raises(env): + _, svc = env(raises=RuntimeError("docker unavailable")) + asyncio.run(_setup()._deploy_system_agent_after_setup()) + assert svc.calls == [False] + + +def test_the_deploy_is_skipped_without_docker(env): + _, svc = env(docker=False) + asyncio.run(_setup()._deploy_system_agent_after_setup()) + assert svc.calls == [], "demo mode (no Docker client): nothing to deploy, as at boot" + + +def test_a_refused_setup_schedules_nothing(env): + env(users={"admin": {"username": "admin", "password": HASH}}) + bg = BackgroundTasks() + with pytest.raises(HTTPException) as ei: + _post(bg) + assert ei.value.status_code == 403 + assert bg.tasks == []