diff --git a/agent/scripts/run_agent_service.py b/agent/scripts/run_agent_service.py index 817711a..c74a462 100644 --- a/agent/scripts/run_agent_service.py +++ b/agent/scripts/run_agent_service.py @@ -94,6 +94,8 @@ class Config: self.fiscal_mode = (env.get("FISCAL_MODE") or ("AUTO_APPLY" if requested else "DRY_RUN")).strip().upper() self.requested_writes = requested self.firebird_writes = requested and self.fiscal_mode == "AUTO_APPLY" + jobs_enabled = (env.get("AGENT_JOBS_ENABLED", "1") or "1").strip().upper() + self.agent_jobs_enabled = jobs_enabled not in ("0", "FALSE", "NO") self.poll_interval_seconds = float(env.get("POLL_INTERVAL_SECONDS", "5")) self.agent_version = env.get("AGENT_VERSION", "1.1.1") self.schema_fingerprint = env.get("SCHEMA_FINGERPRINT", REFERENCE_SCHEMA_V1) @@ -221,6 +223,27 @@ def execute_job(job, config, executor, state, env): return None, f"{type(error).__name__}: {error}" +def resolve_installation_id(config, env, state, client): + """Recover and persist the Hub-bound identity; never invent one for an Agent.""" + persisted = state.stored_installation_id() + configured = (env.get("INSTALLATION_ID") or "").strip() or None + if persisted is not None: + return state.installation_id(configured) + if configured is not None: + return state.installation_id(configured) + + configured_agent_id = str(env.get("AGENT_ID") or config.agent_id or "").strip() + if not configured_agent_id: + raise RuntimeError("AGENT_ID is required to recover installation identity") + identity = client.identity() + if str(identity.get("agent_id") or "") != configured_agent_id: + raise RuntimeError("authenticated identity does not match configured Agent") + installation_id = identity.get("installation_id") + if not installation_id: + raise RuntimeError("authenticated identity response has no installation_id") + return state.installation_id(str(installation_id)) + + def run(config, env, *, max_iterations=None, stopper=None): """`stopper` defaults to a signal-based GracefulStop (SIGINT/SIGTERM), which only works from the main thread. A host that manages its own lifecycle @@ -230,7 +253,7 @@ def run(config, env, *, max_iterations=None, stopper=None): executor = load_executor() state = AgentState(config.state_db) client = HubClient(config.hub_url, config.agent_token) - installation_id = state.installation_id() if hasattr(state, "installation_id") else (config.agent_id or "legacy-installation") + installation_id = resolve_installation_id(config, env, state, client) stopper = stopper if stopper is not None else GracefulStop() backoff = 1 @@ -266,6 +289,10 @@ def run(config, env, *, max_iterations=None, stopper=None): backoff = min(backoff * 2, _MAX_BACKOFF_SECONDS) continue + if not config.agent_jobs_enabled: + time.sleep(config.poll_interval_seconds) + continue + try: job = client.next_job() except Exception as error: diff --git a/agent/service/gti_agent_windows_service.py b/agent/service/gti_agent_windows_service.py index 80e7835..e1acbbf 100644 --- a/agent/service/gti_agent_windows_service.py +++ b/agent/service/gti_agent_windows_service.py @@ -87,7 +87,15 @@ if win32serviceutil is not None: def main(): if win32serviceutil is None: raise SystemExit("pywin32 is required: pip install pywin32") - win32serviceutil.HandleCommandLine(GTIFiscalAgentService) + if len(sys.argv) == 1: + # SCM launches a service executable without CLI arguments. HandleCommandLine + # requires a command (install/start/debug) and exits with usage when none + # is present, which makes SCM report error 1053 for a frozen service host. + servicemanager.Initialize() + servicemanager.PrepareToHostSingle(GTIFiscalAgentService) + servicemanager.StartServiceCtrlDispatcher() + else: + win32serviceutil.HandleCommandLine(GTIFiscalAgentService) if __name__ == "__main__": diff --git a/agent/src/gti_fiscal_agent/client.py b/agent/src/gti_fiscal_agent/client.py index 8bb5025..7a48a1e 100644 --- a/agent/src/gti_fiscal_agent/client.py +++ b/agent/src/gti_fiscal_agent/client.py @@ -13,6 +13,7 @@ class HubClient: def get_json(self, path): response = httpx.get(f"{self.url}{path}", headers=self._headers(), timeout=30) response.raise_for_status(); return response.json() + def identity(self): return self.get_json("/v1/agents/me/identity") def health(self): response = httpx.get(f"{self.url}/health", timeout=10) return response.status_code == 200 diff --git a/agent/src/gti_fiscal_agent/state.py b/agent/src/gti_fiscal_agent/state.py index 6847125..12bbc10 100644 --- a/agent/src/gti_fiscal_agent/state.py +++ b/agent/src/gti_fiscal_agent/state.py @@ -9,12 +9,31 @@ class AgentState: self.connection.executescript("CREATE TABLE IF NOT EXISTS product_state(product_id TEXT PRIMARY KEY, source_fingerprint TEXT NOT NULL, dataset_version TEXT NOT NULL, engine_version TEXT NOT NULL, status TEXT NOT NULL, last_processed_at TEXT, last_result_hash TEXT, reason_code TEXT); CREATE TABLE IF NOT EXISTS outbox(product_id TEXT PRIMARY KEY, source_fingerprint TEXT NOT NULL, status TEXT NOT NULL, retries INTEGER NOT NULL DEFAULT 0); CREATE TABLE IF NOT EXISTS ipi_write_log(product_id TEXT NOT NULL, source_fingerprint TEXT NOT NULL, decision_status TEXT, ipi_status TEXT, write_status TEXT NOT NULL, before_value TEXT, proposed_value TEXT, result TEXT, reason_code TEXT, timestamp TEXT NOT NULL);") self.connection.execute("CREATE TABLE IF NOT EXISTS installation_identity (id INTEGER PRIMARY KEY CHECK(id=1), installation_id TEXT NOT NULL)") self.connection.commit() - def installation_id(self): + def stored_installation_id(self): row = self.connection.execute("SELECT installation_id FROM installation_identity WHERE id=1").fetchone() - if row: - return row["installation_id"] - value = str(uuid.uuid4()) - self.connection.execute("INSERT INTO installation_identity(id, installation_id) VALUES(1, ?)", (value,)); self.connection.commit() + if row is None: + return None + try: + return str(uuid.UUID(row["installation_id"])) + except (TypeError, ValueError, AttributeError) as error: + raise ValueError("stored installation_id is not a valid UUID") from error + def installation_id(self, candidate=None): + existing = self.stored_installation_id() + if existing: + if candidate is not None and str(uuid.UUID(str(candidate))) != existing: + raise ValueError("configured installation_id conflicts with persisted identity") + return existing + if candidate is None or not str(candidate).strip(): + raise ValueError("installation_id is required; refusing to generate a new identity") + try: + value = str(uuid.UUID(str(candidate))) + except (TypeError, ValueError, AttributeError) as error: + raise ValueError("installation_id must be a valid UUID") from error + self.connection.execute("INSERT OR IGNORE INTO installation_identity(id, installation_id) VALUES(1, ?)", (value,)) + self.connection.commit() + row = self.connection.execute("SELECT installation_id FROM installation_identity WHERE id=1").fetchone() + if row is None or str(uuid.UUID(row["installation_id"])) != value: + raise ValueError("persisted installation_id conflicts with candidate") return value def enqueue(self, product_id, source_fingerprint, dataset_version, engine_version): row = self.connection.execute("SELECT * FROM product_state WHERE product_id=?", (product_id,)).fetchone() diff --git a/agent/tests/test_client.py b/agent/tests/test_client.py new file mode 100644 index 0000000..05dde47 --- /dev/null +++ b/agent/tests/test_client.py @@ -0,0 +1,34 @@ +import httpx + +from gti_fiscal_agent.client import HubClient + + +def test_identity_requests_authenticated_identity_only(monkeypatch): + calls = {} + payload = { + "agent_id": 2, + "installation_id": "00000000-0000-4000-8000-000000000222", + "hostname": "DESKTOP-B0HL02I", + "version": "1.1.1", + "status": "OFFLINE", + } + + class Response: + status_code = 200 + + def raise_for_status(self): + return None + + def json(self): + return payload + + def fake_get(url, *, headers, timeout): + calls.update(url=url, headers=headers, timeout=timeout) + return Response() + + monkeypatch.setattr(httpx, "get", fake_get) + client = HubClient("https://hub.example", "test-agent-token") + assert client.identity() == payload + + assert calls["url"] == "https://hub.example/v1/agents/me/identity" + assert calls["headers"] == {"Authorization": "Bearer test-agent-token"} diff --git a/agent/tests/test_run_agent_service.py b/agent/tests/test_run_agent_service.py index 87ddd8b..750a4ea 100644 --- a/agent/tests/test_run_agent_service.py +++ b/agent/tests/test_run_agent_service.py @@ -22,6 +22,19 @@ service = load_module() class _FakeState: def __init__(self): self.logged = [] + self.identity = None + + def stored_installation_id(self): + return self.identity + + def installation_id(self, candidate=None): + if self.identity and candidate and self.identity != candidate: + raise ValueError("configured installation_id conflicts with persisted identity") + if self.identity is None: + if not candidate: + raise ValueError("installation_id is required") + self.identity = candidate + return self.identity def log_ipi_write(self, *args): self.logged.append(args) @@ -61,7 +74,7 @@ def _config(**overrides): base = dict( hub_url="http://localhost:8000", agent_id="a1", agent_token="tok", company_id="c1", company_uf="BA", database_path=AUTHORIZED, firebird_writes=False, poll_interval_seconds=0.01, - agent_version="0.1.0", schema_fingerprint="fp", state_db=":memory:", + agent_version="0.1.0", schema_fingerprint="fp", state_db=":memory:", agent_jobs_enabled=True, authorized_write_database_path=None, expected_schema_fingerprint="fp", ) base.update(overrides) @@ -74,6 +87,13 @@ def test_config_defaults_from_env(): assert config.firebird_writes is False assert config.hub_url == "http://localhost:8000" assert config.poll_interval_seconds == 5.0 + assert config.agent_jobs_enabled is True + + +def test_config_can_disable_agent_job_polling(): + config = service.Config({"FIREBIRD_DATABASE_PATH": AUTHORIZED, "AGENT_JOBS_ENABLED": "0"}) + + assert config.agent_jobs_enabled is False def test_config_reads_gti_hub_url_and_writes_flag(): @@ -83,6 +103,54 @@ def test_config_reads_gti_hub_url_and_writes_flag(): assert config.firebird_writes is True +def test_identity_recovery_persists_existing_hub_installation_id(tmp_path): + config = _config(agent_id="2") + state = service.AgentState(tmp_path / "agent.db") + installation_id = "00000000-0000-4000-8000-000000000222" + + class FakeIdentityClient: + calls = 0 + + def identity(self): + self.calls += 1 + return {"agent_id": 2, "installation_id": installation_id} + + client = FakeIdentityClient() + recovered = service.resolve_installation_id(config, {}, state, client) + + assert recovered == installation_id + assert state.installation_id() == installation_id + assert client.calls == 1 + + restarted = service.resolve_installation_id(config, {}, state, client) + assert restarted == installation_id + assert client.calls == 1 + + +def test_identity_recovery_fails_closed_for_another_agent_or_missing_id(tmp_path): + config = _config(agent_id="2") + + class FakeIdentityClient: + def __init__(self, payload): + self.payload = payload + def identity(self): + return self.payload + + state = service.AgentState(tmp_path / "agent.db") + with pytest.raises(RuntimeError, match="identity does not match configured Agent"): + service.resolve_installation_id( + config, + {}, + state, + FakeIdentityClient({"agent_id": 3, "installation_id": "00000000-0000-4000-8000-000000000333"}), + ) + assert state.connection.execute("SELECT COUNT(*) FROM installation_identity").fetchone()[0] == 0 + + with pytest.raises(RuntimeError, match="identity response has no installation_id"): + service.resolve_installation_id(config, {}, state, FakeIdentityClient({"agent_id": 2})) + assert state.connection.execute("SELECT COUNT(*) FROM installation_identity").fetchone()[0] == 0 + + def test_path_guard_fails_closed_before_any_write(monkeypatch): config = _config(firebird_writes=True, database_path=r"D:\PRODUCAO\ERP.FDB") executor = _FakeExecutor(path_guard_ok=False) @@ -226,6 +294,57 @@ class _ExternalStopFlag: self.stop = False +def test_run_sends_heartbeat_but_never_polls_when_jobs_disabled(monkeypatch, tmp_path): + stopper = _ExternalStopFlag() + fake_hub = _FakeHubForLoop() + installation_id = "00000000-0000-4000-8000-000000000222" + calls = {"identity": 0, "next_job": 0, "heartbeat": 0, "heartbeat_installation_id": None} + + def identity(): + calls["identity"] += 1 + return {"agent_id": 2, "installation_id": installation_id} + + def heartbeat_metadata(payload): + calls["heartbeat"] += 1 + calls["heartbeat_installation_id"] = payload["installation_id"] + stopper.stop = True + return {"agent_id": 2} + + def forbidden_next_job(): + calls["next_job"] += 1 + raise AssertionError("jobs are disabled; next_job must not be called") + + fake_hub.identity = identity + fake_hub.heartbeat_metadata = heartbeat_metadata + fake_hub.next_job = forbidden_next_job + monkeypatch.setattr(service, "HubClient", lambda *a, **k: fake_hub) + monkeypatch.setattr(service, "load_executor", lambda: _FakeExecutor()) + monkeypatch.setattr(service.time, "sleep", lambda _seconds: None) + + config = _config( + agent_id="2", + agent_jobs_enabled=False, + poll_interval_seconds=0, + state_db=str(tmp_path / "agent.db"), + ) + env = { + "AGENT_ID": "2", + "FIREBIRD_DATABASE_PATH": AUTHORIZED, + "FIREBIRD_WRITES": "0", + "FISCAL_MODE": "DRY_RUN", + } + + service.run(config, env, stopper=stopper) + + assert calls == { + "identity": 1, + "next_job": 0, + "heartbeat": 1, + "heartbeat_installation_id": installation_id, + } + assert service.AgentState(config.state_db).stored_installation_id() == installation_id + + def test_run_honors_an_externally_supplied_stopper(monkeypatch): # This is exactly the pattern the Windows Service wrapper uses: it never # constructs GracefulStop() (which would crash off the main thread), it @@ -248,7 +367,7 @@ def test_run_honors_an_externally_supplied_stopper(monkeypatch): stopper.stop = True monkeypatch.setattr(service.time, "sleep", counting_sleep) - service.run(config, {}, stopper=stopper) # no max_iterations — only the external flag can stop it + service.run(config, {"AGENT_ID": "2", "INSTALLATION_ID": "00000000-0000-4000-8000-000000000222"}, stopper=stopper) # no max_iterations — only the external flag can stop it assert stopper.stop is True assert calls["n"] >= 3 @@ -264,7 +383,7 @@ def test_run_survives_hub_down_then_processes_one_job_and_stops(monkeypatch): monkeypatch.setattr(service.time, "sleep", lambda seconds: None) # first heartbeat call raises -> backoff branch; second call onward succeeds. - service.run(config, {}, max_iterations=4) + service.run(config, {"AGENT_ID": "2", "INSTALLATION_ID": "00000000-0000-4000-8000-000000000222"}, max_iterations=4) assert fake_hub.heartbeat_calls >= 2 assert len(fake_hub.completed) == 1 diff --git a/agent/tests/test_state.py b/agent/tests/test_state.py index c12f930..244e61b 100644 --- a/agent/tests/test_state.py +++ b/agent/tests/test_state.py @@ -1,3 +1,5 @@ +import pytest + from gti_fiscal_agent.state import AgentState @@ -18,3 +20,37 @@ def test_ipi_write_log_records_per_product_audit(tmp_path): assert log[0]["write_status"] == "SUCCEEDED" assert log[0]["before_value"] is None assert log[0]["proposed_value"] == "3.25" + + +def test_installation_identity_requires_an_existing_or_explicit_id(tmp_path): + state = AgentState(tmp_path / "agent.db") + + assert state.stored_installation_id() is None + with pytest.raises(ValueError, match="installation_id is required"): + state.installation_id() + + assert state.connection.execute("SELECT COUNT(*) FROM installation_identity").fetchone()[0] == 0 + + +def test_installation_identity_persists_and_reuses_explicit_id(tmp_path): + db_path = tmp_path / "agent.db" + state = AgentState(db_path) + existing_id = "00000000-0000-4000-8000-000000000222" + + assert state.installation_id(existing_id) == existing_id + assert state.installation_id() == existing_id + assert state.stored_installation_id() == existing_id + + restarted_state = AgentState(db_path) + assert restarted_state.installation_id() == existing_id + + +def test_installation_identity_rejects_conflicting_or_malformed_ids(tmp_path): + state = AgentState(tmp_path / "agent.db") + existing_id = "00000000-0000-4000-8000-000000000222" + state.installation_id(existing_id) + + with pytest.raises(ValueError, match="conflicts"): + state.installation_id("00000000-0000-4000-8000-000000000223") + with pytest.raises(ValueError, match="UUID"): + state.installation_id("not-a-uuid") diff --git a/agent/tests/test_windows_service_host.py b/agent/tests/test_windows_service_host.py new file mode 100644 index 0000000..94a4d5d --- /dev/null +++ b/agent/tests/test_windows_service_host.py @@ -0,0 +1,58 @@ +import importlib.util +import sys +from pathlib import Path +from types import SimpleNamespace + + +SERVICE_WRAPPER = Path(__file__).parents[1] / "service" / "gti_agent_windows_service.py" + + +def load_service_wrapper(): + spec = importlib.util.spec_from_file_location("gti_agent_windows_service_test", SERVICE_WRAPPER) + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def test_main_enters_scm_dispatcher_when_started_without_command(monkeypatch): + service = load_service_wrapper() + calls = [] + + class FakeServiceManager: + def Initialize(self): + calls.append("initialize") + + def PrepareToHostSingle(self, service_class): + calls.append(("prepare", service_class)) + + def StartServiceCtrlDispatcher(self): + calls.append("dispatcher") + + def forbidden_handle_command_line(service_class): + raise AssertionError("SCM start must not be parsed as a CLI command") + + monkeypatch.setattr(service, "servicemanager", FakeServiceManager()) + monkeypatch.setattr(service, "win32serviceutil", SimpleNamespace(HandleCommandLine=forbidden_handle_command_line)) + monkeypatch.setattr(service.sys, "argv", [str(SERVICE_WRAPPER)]) + + service.main() + + assert calls == ["initialize", ("prepare", service.GTIFiscalAgentService), "dispatcher"] + + +def test_main_preserves_pywin32_cli_commands(monkeypatch): + service = load_service_wrapper() + called = [] + + class ForbiddenServiceManager: + def __getattr__(self, name): + raise AssertionError("CLI commands must not enter the SCM dispatcher") + + monkeypatch.setattr(service, "servicemanager", ForbiddenServiceManager()) + monkeypatch.setattr(service, "win32serviceutil", SimpleNamespace(HandleCommandLine=lambda cls: called.append(cls))) + monkeypatch.setattr(service.sys, "argv", [str(SERVICE_WRAPPER), "start"]) + + service.main() + + assert called == [service.GTIFiscalAgentService] diff --git a/central/app/agent_distribution.py b/central/app/agent_distribution.py index 15ab673..b7a6bfd 100644 --- a/central/app/agent_distribution.py +++ b/central/app/agent_distribution.py @@ -194,6 +194,7 @@ def router(session_factory): target_type="agent", target_id=str(agent.id), context={"ip": ip, "installation": enr.enrollment_uuid}) session.commit() return {"agent_id": agent.id, "agent_token": plain_token, "tenant_id": enr.tenant_id, + "installation_id": enr.enrollment_uuid, "hub_url": config.settings.base_url, "hostname_mismatch": mismatch} except enrollment.EnrollmentRejected as rejected: session.rollback() diff --git a/central/app/api.py b/central/app/api.py index df1a181..50bc678 100644 --- a/central/app/api.py +++ b/central/app/api.py @@ -226,6 +226,26 @@ def router(session_factory, bootstrap_token): return {"event_id": event.id} finally: session.close() + @routes.get("/agents/me/identity") + def agent_identity(identity=Depends(principal)): + """Return only the authenticated Agent's existing installation identity.""" + session, agent = identity + try: + installation = session.scalar(select(Installation).where(Installation.agent_id == agent.id)) + last_seen = agent.last_seen_at + if last_seen is not None and last_seen.tzinfo is None: + last_seen = last_seen.replace(tzinfo=timezone.utc) + online = last_seen is not None and (datetime.now(timezone.utc) - last_seen).total_seconds() <= settings.agent_online_threshold_seconds + return { + "agent_id": agent.id, + "installation_id": installation.installation_id if installation else None, + "hostname": installation.hostname if installation else None, + "version": agent.agent_version, + "status": "ONLINE" if online else "OFFLINE", + } + finally: + session.close() + @routes.get("/agents/me") def agent_status(identity=Depends(principal)): session, agent = identity diff --git a/central/tests/test_install_code_enrollment.py b/central/tests/test_install_code_enrollment.py index 45432ff..496d17a 100644 --- a/central/tests/test_install_code_enrollment.py +++ b/central/tests/test_install_code_enrollment.py @@ -128,6 +128,7 @@ def test_enroll_with_install_code_alone_creates_agent(hub, release_id): assert enrolled.status_code == 200 body = enrolled.json() assert body["agent_id"] and body["agent_token"] and body["hostname_mismatch"] is False + assert body["installation_id"] == uuid with factory() as session: agent = session.get(Agent, body["agent_id"]) @@ -416,4 +417,4 @@ def test_revoke_clears_code_and_is_audited(hub, tmp_path): enr = session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == installation_uuid)) assert enr.status == "REVOKED" and enr.install_code_hash is None events = {e.action for e in audits(factory)} - assert "INSTALLATION_CODE_CREATED" in events and "INSTALLATION_CODE_REVOKED" in events and "INSTALLATION_REVOKED" in events \ No newline at end of file + assert "INSTALLATION_CODE_CREATED" in events and "INSTALLATION_CODE_REVOKED" in events and "INSTALLATION_REVOKED" in events diff --git a/central/tests/test_jobs.py b/central/tests/test_jobs.py index f2e9b34..f6f792d 100644 --- a/central/tests/test_jobs.py +++ b/central/tests/test_jobs.py @@ -5,7 +5,7 @@ from sqlalchemy.pool import StaticPool from central.app.jobs import claim_next_job, complete_job, create_job, fail_job from central.app.main import create_app -from central.app.models import Agent, Base, BlockedProduct, Job, Tenant +from central.app.models import Agent, Base, BlockedProduct, Installation, Job, Tenant def _make_agent(session): @@ -118,6 +118,63 @@ def test_agent_status_derives_online_offline(): assert "company_id" in body +def test_agent_identity_endpoint_returns_existing_installation_identity_only(): + client, _ = _client() + headers = {"Authorization": "Bearer test-token"} + installation_id = "00000000-0000-4000-8000-000000000222" + + heartbeat = client.post( + "/v1/agents/heartbeat", + json={ + "installation_id": installation_id, + "hostname": "DESKTOP-B0HL02I", + "agent_version": "1.1.1", + "status": "IDLE", + }, + headers=headers, + ) + assert heartbeat.status_code == 200 + + response = client.get("/v1/agents/me/identity", headers=headers) + + assert response.status_code == 200 + assert response.json() == { + "agent_id": heartbeat.json()["agent_id"], + "installation_id": installation_id, + "hostname": "DESKTOP-B0HL02I", + "version": "1.1.1", + "status": "ONLINE", + } + + +def test_agent_identity_endpoint_returns_null_installation_without_creating_one(): + client, engine = _client() + headers = {"Authorization": "Bearer test-token"} + + response = client.get("/v1/agents/me/identity", headers=headers) + + assert response.status_code == 200 + body = response.json() + assert body == { + "agent_id": body["agent_id"], + "installation_id": None, + "hostname": None, + "version": None, + "status": "OFFLINE", + } + assert isinstance(body["agent_id"], int) and body["agent_id"] > 0 + with Session(engine) as session: + assert session.query(Installation).count() == 0 + + +def test_agent_identity_endpoint_requires_agent_authentication(): + client, _ = _client() + + response = client.get("/v1/agents/me/identity") + + assert response.status_code == 401 + + def test_job_creation_rejects_unknown_kind(): client, _ = _client() headers = {"Authorization": "Bearer test-token"} diff --git a/docs/superpowers/plans/2026-09-23-agent-installation-identity-heartbeat.md b/docs/superpowers/plans/2026-09-23-agent-installation-identity-heartbeat.md new file mode 100644 index 0000000..eb45f4d --- /dev/null +++ b/docs/superpowers/plans/2026-09-23-agent-installation-identity-heartbeat.md @@ -0,0 +1,249 @@ +# Agent Installation Identity Recovery and First Heartbeat Implementation Plan + +> **For agentic workers:** Use `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox syntax for tracking. + +**Goal:** Recover and persist Agent 2's existing Hub `installation_id`, send exactly the first safe heartbeat, and stop without claiming jobs or writing Firebird. + +**Architecture:** The Hub exposes the already-linked installation identity over an authenticated, read-only endpoint. New enrollments return that same ID so Setup can persist it; legacy installations recover it at runtime and commit it into the existing SQLite identity table before heartbeat. `AGENT_JOBS_ENABLED=0` skips the job API entirely during activation. + +**Tech Stack:** FastAPI, SQLAlchemy, pytest, Python `fdb`, SQLite local state, PyInstaller onedir service host, Windows SCM / PowerShell. + +## Global Constraints + +- Keep Agent ID `2`; do not create an Agent, reenroll, or create a second Installation. +- Never generate a fallback `installation_id` for an already-registered Agent. +- Do not call `/v1/jobs/next`, classification endpoints, or any job execution path during activation. +- Preserve `FIREBIRD_WRITES=0`, `FISCAL_MODE=DRY_RUN`, and empty `AUTHORIZED_WRITE_DATABASE_PATH`. +- All Firebird connections used by preflight remain read-only; issue no Firebird DML/DDL. +- Do not rerun Setup.exe or replace `agent.env` / `credentials.bin` during service-host update. +- Keep published `GTIFiscalAgent-Setup-1.1.1.exe` immutable; update only the installed service runtime. +- Never print or log `FB_PASSWORD`, the Agent bearer token, or credential-file contents. +- Do not commit unless the operator explicitly requests a commit. + +--- + +### Task 1: Hub installation identity response + +**Files:** +- Modify: `central/app/api.py` +- Test: `central/tests/test_jobs.py` + +**Interfaces:** +- Add authenticated `GET /v1/agents/me/identity`. +- Return exactly `{agent_id, installation_id, hostname, version, status}`; `installation_id` and `hostname` are `None` if the Agent has no Installation. +- Do not change the existing `GET /v1/agents/me` response. + +- [ ] **Step 1: Write the failing identity endpoint tests** + +Add tests that create an Agent with one Installation (`installation_id` fixed UUID, hostname `DESKTOP-B0HL02I`, version `1.1.1`) and assert: + +```python +response = client.get("/v1/agents/me/identity", headers=headers) +assert response.status_code == 200 +assert response.json() == { + "agent_id": agent.id, + "installation_id": installation.installation_id, + "hostname": "DESKTOP-B0HL02I", + "version": "1.1.1", + "status": "OFFLINE", +} +``` + +Also assert unauthenticated requests return 401 and an Agent without an Installation returns HTTP 200 with nullable identity fields, without creating an Installation. + +- [ ] **Step 2: Run the focused tests and verify failure** + +Run: `python -m pytest central/tests/test_jobs.py -q` +Expected: the identity route returns 404 before implementation. + +- [ ] **Step 3: Implement the authenticated endpoint** + +Use the existing `principal` dependency, query `Installation` by `agent.id`, and derive status with the same online-threshold rule as `/v1/agents/me`. The route must not commit or mutate database state. + +- [ ] **Step 4: Run the focused tests** + +Run: `python -m pytest central/tests/test_jobs.py -q` +Expected: all tests pass; existing `/v1/agents/me` tests remain unchanged and green. + +### Task 2: Return and persist identity during new enrollment + +**Files:** +- Modify: `central/app/agent_distribution.py` +- Test: `central/tests/test_install_code_enrollment.py` +- Modify: `installer/install_common.py` +- Test: `tests/test_installer_artifacts.py` + +**Interfaces:** +- Successful `/v1/agents/enroll` response adds `installation_id=enr.enrollment_uuid`. +- `write_agent_env(install_dir_path, enroll_result)` writes `INSTALLATION_ID` as a non-secret field. It never writes `agent_token` or `AGENT_TOKEN`. + +- [ ] **Step 1: Add failing enrollment and Setup-env assertions** + +In the existing install-code happy-path test, assert `body["installation_id"] == uuid`. In the installer artifact test, pass a fixed ID such as `00000000-0000-4000-8000-000000000222` and assert the env includes it while the token is absent. + +- [ ] **Step 2: Run focused tests and verify failure** + +Run: `python -m pytest central/tests/test_install_code_enrollment.py::test_enroll_with_install_code_alone_creates_agent tests/test_installer_artifacts.py -q` +Expected: failures show the response/env currently omit `installation_id`. + +- [ ] **Step 3: Add the returned ID and env serialization** + +Add `"installation_id": enr.enrollment_uuid` to the successful enrollment JSON. Add `INSTALLATION_ID` to `write_agent_env`; do not change credential storage or Firebird guard values. + +- [ ] **Step 4: Run focused tests** + +Run the same command as Step 2. +Expected: enrollment and installer artifact tests pass; `agent.env` remains token-free with `FIREBIRD_WRITES=0` and `FISCAL_MODE=DRY_RUN`. + +### Task 3: Persist or recover identity before heartbeat + +**Files:** +- Modify: `agent/src/gti_fiscal_agent/state.py` +- Modify: `agent/src/gti_fiscal_agent/client.py` +- Modify: `agent/scripts/run_agent_service.py` +- Test: `agent/tests/test_state.py` +- Test: `agent/tests/test_run_agent_service.py` + +**Interfaces:** +- `AgentState.installation_id(candidate: str | None = None) -> str` returns the persisted UUID; if no UUID is stored, it persists a valid candidate; if neither exists, it raises instead of generating a random UUID. A different candidate from an already-persisted UUID raises. +- `HubClient.identity() -> dict` calls authenticated `GET /v1/agents/me/identity`. +- `resolve_installation_id(config, env, state, client) -> str` selects persisted state, then `INSTALLATION_ID`, then authenticated identity; verifies returned `agent_id` equals `config.agent_id`; persists before returning. + +- [ ] **Step 1: Write failing state identity tests** + +Cover: empty store plus valid candidate persists and returns it; second call returns the same ID; conflicting candidate raises; missing candidate does not generate; malformed UUID raises. + +- [ ] **Step 2: Run state tests and verify failure** + +Run: `python -m pytest agent/tests/test_state.py -q` +Expected: the missing identity case currently generates a UUID and the persistence API is absent. + +- [ ] **Step 3: Implement deterministic SQLite identity persistence** + +Validate candidate values with `uuid.UUID`. Within a SQLite transaction, read the existing row, compare any candidate, insert only when absent, and return the single stored canonical UUID. Remove random UUID creation from the registered-agent path. + +- [ ] **Step 4: Write failing runtime bootstrap tests** + +Use a fake Hub identity response and fake state. Assert missing local identity calls `identity()` once, verifies matching Agent ID, persists the returned UUID, and uses it in heartbeat. Assert a mismatched Agent ID, null ID, or identity request error fails before heartbeat. Assert an already-persisted ID avoids the identity request. + +- [ ] **Step 5: Run runtime tests and verify failure** + +Run: `python -m pytest agent/tests/test_run_agent_service.py -q` +Expected: current loop calls the random-UUID-producing state method and has no authenticated identity bootstrap. + +- [ ] **Step 6: Implement identity bootstrap before the heartbeat loop** + +Add `HubClient.identity()`, implement `resolve_installation_id`, and invoke it after constructing the Hub client/state but before entering the loop. An identity failure must exit before heartbeat and before job polling. + +- [ ] **Step 7: Run focused state/runtime tests** + +Run: `python -m pytest agent/tests/test_state.py agent/tests/test_run_agent_service.py -q` +Expected: persisted/recovered identity tests pass and mismatches fail closed. + +### Task 4: Disable job polling without disabling heartbeat + +**Files:** +- Modify: `agent/scripts/run_agent_service.py` +- Test: `agent/tests/test_run_agent_service.py` +- Modify on this host only: `C:\Program Files\GTI\FiscalAgent\agent.env` + +**Interfaces:** +- `Config.agent_jobs_enabled` parses `AGENT_JOBS_ENABLED`, default `True`. +- When false, the loop sleeps for `poll_interval_seconds` and continues after heartbeat without calling `HubClient.next_job()`. + +- [ ] **Step 1: Add the failing no-poll test** + +Use a fake Hub whose `next_job()` raises if called. Make its first heartbeat set the external stop flag. Run one loop cycle with `AGENT_JOBS_ENABLED=0` and assert one heartbeat, zero `next_job` calls, and zero job/classification calls. + +- [ ] **Step 2: Run the focused test and verify failure** + +Run: `python -m pytest agent/tests/test_run_agent_service.py -q` +Expected: the current loop calls `next_job()` immediately after heartbeat. + +- [ ] **Step 3: Implement the gate with a backward-compatible default** + +Parse `AGENT_JOBS_ENABLED` as enabled unless its stripped, uppercased value is `0`, `FALSE`, or `NO`. After successful heartbeat, if disabled, sleep once and continue before constructing or calling the job endpoint. + +- [ ] **Step 4: Run focused runtime tests** + +Run: `python -m pytest agent/tests/test_run_agent_service.py -q` +Expected: disabled polling makes no `next_job` request; default configuration retains the existing job behavior. + +### Task 5: Full regressions and guard review + +**Files:** +- Verify: `central/tests/`, `agent/tests/`, `tests/` + +- [ ] **Step 1: Run Hub and Agent regressions** + +Run: `python -m pytest central/tests -q` and `python -m pytest agent/tests -q`. +Expected: both suites pass. + +- [ ] **Step 2: Run repository offline regressions** + +Run: `python -m pytest tests -q`. +Expected: all tests pass with only the repository's pre-existing skip. + +- [ ] **Step 3: Verify write and secret invariants** + +Assert tests and source still guarantee `FIREBIRD_WRITES=0`, `FISCAL_MODE=DRY_RUN`, empty `AUTHORIZED_WRITE_DATABASE_PATH`, no token in `agent.env`, and identity endpoint contains only the approved fields. Do not connect to Firebird for this check. + +### Task 6: Deploy the API and update only the installed service runtime + +**Files/artifacts:** +- Deploy Hub source: `central/app/api.py`, `central/app/agent_distribution.py` +- Build artifact: `dist/installer/GTIFiscalAgentService/` only +- Existing install: `C:\Program Files\GTI\FiscalAgent\GTIFiscalAgentService\` + +- [ ] **Step 1: Deploy the Hub code without a migration** + +Use the approved production deployment path and preserve the existing Hub `.env`, PostgreSQL volume, release volume, SSO, and DNS. Build/restart only `fiscal-hub`; verify `/health`, `/version`, and authenticated `/v1/agents/me/identity` return Agent 2's already-existing identity. Do not query the database directly to retrieve the ID. + +- [ ] **Step 2: Build only the service-host onedir** + +Record SHA-256 of `dist/installer/GTIFiscalAgent-Setup-1.1.1.exe` before building. Verify no unversioned `GTIFiscalAgent-Setup.exe` exists, then run `scripts/build_windows_installer.ps1 -SkipSetup -SkipUninstall`. Confirm the versioned Setup hash is unchanged and no Setup.exe is run. + +- [ ] **Step 3: Back up and replace only the service runtime** + +With `GTIFiscalAgent` stopped, copy the current installed `GTIFiscalAgentService` directory to a timestamped local backup. Replace only that directory with the newly built service onedir. Leave `agent.env`, `credentials.bin`, `Uninstall.exe`, registry/ARP entries, and state files untouched. + +- [ ] **Step 4: Prepare the existing installation for heartbeat-only operation** + +Elevated-edit `agent.env` to set `AGENT_JOBS_ENABLED=0`, preserving `FIREBIRD_WRITES=0`, `FISCAL_MODE=DRY_RUN`, the existing Agent ID, Firebird configuration, and SYSTEM/Administrators ACL. Do not put the bearer token in this file. + +- [ ] **Step 5: Verify recovery before SCM start** + +Run the installed runtime's read-only identity bootstrap/preflight with the existing DPAPI credential. Confirm recovered `agent_id=2`, `installation_id` equals the endpoint value, the SQLite state contains that same ID, and jobs are disabled. If any check fails, leave the service stopped. + +### Task 7: First heartbeat only, then stop + +**Files:** +- Existing Windows service: `GTIFiscalAgent` +- Existing Hub identity endpoint: `GET /v1/agents/me/identity` + +- [ ] **Step 1: Capture a baseline** + +Before service start, record the existing local `ipi_write_log` row count and confirm service state is STOPPED. Do not call `/v1/jobs/next` or submit classification requests. + +- [ ] **Step 2: Start the existing service** + +Run `sc.exe start GTIFiscalAgent`; wait for SCM state RUNNING and verify the identity endpoint shows `agent_id=2`, the same stored `installation_id`, correct hostname, version `1.1.1`, and status ONLINE. + +- [ ] **Step 3: Stop immediately after first heartbeat** + +Run `sc.exe stop GTIFiscalAgent`; verify STOPPED. Keep `AGENT_JOBS_ENABLED=0` in `agent.env`. + +- [ ] **Step 4: Verify zero jobs and writes** + +Compare local `ipi_write_log` count with baseline; assert unchanged and equal to zero. Confirm no classification request or job was sent, `FIREBIRD_WRITES=0`, and `WRITE_ATTEMPTS=0` / `WRITE_SUCCESSES=0`. Verify authenticated identity still maps to Agent 2 and the original installation ID; do not create or enroll anything. + +- [ ] **Step 5: Verify user-visible status if panel access is available** + +Confirm Agent 2 displays ONLINE with the host name and version 1.1.1. If the panel requires unavailable human authentication, report the authenticated identity endpoint evidence and leave the panel UI item as a blocker; do not bypass authentication. + +## Rollback and stop gates + +- If Hub identity response is absent, mismatched, or does not name Agent 2, stop before writing the local identity or starting SCM. +- If Hub deployment or service-host replacement fails, restore the backed-up service directory and leave the service stopped. +- If `AGENT_JOBS_ENABLED=0` is not verified, do not start the service. +- If preflight, identity, hostname, version, or write guards fail, stop and report; do not retry enrollment, generate another UUID, run a job, or write Firebird. diff --git a/docs/superpowers/specs/2026-09-23-agent-installation-identity-heartbeat-design.md b/docs/superpowers/specs/2026-09-23-agent-installation-identity-heartbeat-design.md new file mode 100644 index 0000000..302dbe5 --- /dev/null +++ b/docs/superpowers/specs/2026-09-23-agent-installation-identity-heartbeat-design.md @@ -0,0 +1,107 @@ +# Agent Installation Identity Recovery and First Heartbeat + +## Goal and scope + +Activate the already-enrolled Agent 2 on this Windows host without creating a +new Agent, enrolling again, or registering a second installation. Recover and +persist its existing Hub `installation_id`, send a first real heartbeat, and +stop before any job is claimed. Firebird stays strictly read-only and the +installation remains `FIREBIRD_WRITES=0` / `FISCAL_MODE=DRY_RUN`. + +## Current root cause + +`POST /v1/agents/enroll` creates the Hub `Installation` with +`installation_id=enrollment_uuid`, but its response omits that identity. +`installer/setup_main.py` therefore does not persist it. On a host with no +`agent_state.sqlite`, `AgentState.installation_id()` invents a UUID. The Hub +heartbeat route rejects a different UUID for an Agent that already has an +Installation, preventing the first heartbeat. + +The service loop also calls `GET /v1/jobs/next` immediately after a successful +heartbeat, so an explicit runtime gate is required for this activation. + +## Design + +### Identity delivery and recovery + +- Return `installation_id` in the successful enrollment response. The value is + the existing `enrollment_uuid` used to create the Hub Installation. +- Persist this non-secret value in `agent.env` for new installs. +- Add authenticated `GET /v1/agents/me/identity`, returning only + `agent_id`, `installation_id`, `hostname`, `version`, and `status`. Keep the + existing `/v1/agents/me` response unchanged for compatibility. +- At runtime, identity precedence is: existing SQLite `installation_identity`, + then configured `INSTALLATION_ID`, then the authenticated identity endpoint. + Recovery must verify the returned `agent_id` equals local `AGENT_ID`; absent, + malformed, or mismatched identity fails closed without generating a UUID or + sending a heartbeat. +- Persist a recovered/configured UUID atomically in the existing SQLite state + before sending heartbeat. Restarts reuse this stored ID. A conflict between + configured and already-persisted values fails closed. + +### Job-poll gate + +- Add `AGENT_JOBS_ENABLED`, defaulting to `1` to preserve current behavior for + existing deployments. +- When it is `0`, the loop may send heartbeat and sleep, but must not call + `next_job`, claim jobs, classify products, or execute jobs. +- Set `AGENT_JOBS_ENABLED=0` only for this existing installation while + validating the first heartbeat. Leave it disabled after the service is + stopped, until a separate operator action authorizes job processing. + +### Deployment boundary + +- The identity endpoint is Hub application code only; it needs no DB migration. + Deploy that Hub code without changing the immutable Agent release or the + production SSO/DNS configuration. +- The installed service binary must contain the identity recovery and job gate. + Build only the service-host onedir and update the existing + `GTIFiscalAgentService` payload while the service is stopped. Do not run Setup, + replace `agent.env`/`credentials.bin`, recreate an Agent, or enroll again. +- Keep the published 1.1.1 Setup artifact immutable. The installer source fix + gets regression coverage for a future release; this activation updates only + the installed service runtime needed by the current host. + +### Write safety + +No Firebird write path is added or enabled. The existing agent configuration +must continue to contain `FIREBIRD_WRITES=0`, `FISCAL_MODE=DRY_RUN`, and an empty +`AUTHORIZED_WRITE_DATABASE_PATH`. The first heartbeat reports metadata only; +this mission must not call `/jobs/next` or a classification endpoint. + +## Failure behavior + +- Identity endpoint unavailable, no existing installation, or Agent ID mismatch: + fail closed before heartbeat and before job polling. +- SQLite persistence failure: fail closed; do not send a guessed identity. +- Jobs disabled: continue the heartbeat loop without touching the job API. +- If Hub deployment or the service-host update cannot be completed, do not + start the installed service; report the deployment blocker instead. +- Service is stopped immediately after the first successful heartbeat is + observed and verified against Agent 2's existing installation. + +## Regression coverage + +1. Enrollment response returns the exact `enrollment_uuid` as + `installation_id`; Setup persists it without placing any token in `agent.env`. +2. Authenticated identity endpoint returns only the approved identity fields + and derives hostname/version/status from the existing Agent/Installation. +3. Runtime recovers and persists an ID when local state is missing, validates + Agent ID equality, reuses the ID on restart, and fails closed on mismatch or + missing identity. +4. With `AGENT_JOBS_ENABLED=0`, heartbeat runs but `next_job` is never called; + default-enabled behavior remains covered. +5. Existing write-safety tests continue to prove zero Firebird writes in + `DRY_RUN` / `FIREBIRD_WRITES=0`. + +## Acceptance for this activation + +- Existing Agent ID remains 2; no enrollment or Agent creation occurs. +- Recovered identity equals the Hub's existing `Installation.installation_id` + and is persisted locally before start. +- Jobs are disabled before service start; service reaches RUNNING, sends one + successful heartbeat for the existing installation, then is stopped. +- Panel shows Agent 2 online with the correct hostname and version 1.1.1; no + duplicate installation is created. +- `FIREBIRD_WRITES=0`, `WRITE_ATTEMPTS=0`, `WRITE_SUCCESSES=0`; no job polling, + classification, or Firebird writes occur. diff --git a/installer/install_common.py b/installer/install_common.py index d7d3f13..145db42 100644 --- a/installer/install_common.py +++ b/installer/install_common.py @@ -73,6 +73,7 @@ def write_agent_env(install_dir_path: Path, enroll_result: dict) -> None: lines = [ (f"GTI_HUB_URL={enroll_result.get('hub_url') or hub_url()}"), (f"AGENT_ID={enroll_result.get('agent_id') or ''}"), + (f"INSTALLATION_ID={enroll_result.get('installation_id') or ''}"), ("COMPANY_ID="), ("COMPANY_UF="), ("FIREBIRD_DATABASE_PATH="), @@ -182,4 +183,4 @@ def audit_event(agent_token: str, hub: str, event_type: str, message: str) -> No ) response.raise_for_status() except Exception: # noqa: BLE001 — best effort only - return \ No newline at end of file + return diff --git a/tests/test_installer_artifacts.py b/tests/test_installer_artifacts.py index 6da8537..78f1865 100644 --- a/tests/test_installer_artifacts.py +++ b/tests/test_installer_artifacts.py @@ -42,7 +42,7 @@ def test_agent_env_is_secret_free_and_write_safe(tmp_path, install_common, monke monkeypatch.setenv("AGENT_VERSION", "1.1.1") final = ROOT / "release" / "VERSION" monkeypatch.setenv("AGENT_VERSION", final.read_text(encoding="utf-8").strip()) - result = {"agent_id": 42, "agent_token": secret, "hub_url": "https://gtifiscal.gtigrupo.com.br", "agent_version": "1.1.1"} + result = {"agent_id": 42, "agent_token": secret, "hub_url": "https://gtifiscal.gtigrupo.com.br", "agent_version": "1.1.1", "installation_id": "00000000-0000-4000-8000-000000000222"} install_common.write_agent_env(tmp_path, result) env = (tmp_path / "agent.env").read_text(encoding="utf-8") assert "AGENT_TOKEN" not in env @@ -52,6 +52,7 @@ def test_agent_env_is_secret_free_and_write_safe(tmp_path, install_common, monke assert "FISCAL_MODE=DRY_RUN" in env assert "GTI_HUB_URL=https://gtifiscal.gtigrupo.com.br" in env assert "AGENT_ID=42" in env + assert "INSTALLATION_ID=00000000-0000-4000-8000-000000000222" in env assert "FIREBIRD_DATABASE_PATH=" in env for k in ("AGENT_LOG_PATH", "AGENT_STATE_DB", "EXPECTED_SCHEMA_FINGERPRINT", "GTI_WRITE_EXECUTOR_PATH"): assert k in env, k @@ -118,4 +119,4 @@ def test_no_writes_ever_enabled_in_installer_sources(): assert "AUTO_APPLY" not in text assert "FIREBIRD_WRITES=0" not in text # the flag lives only in install_common assert "FISCAL_MODE=DRY_RUN" in install_common_text - assert "FIREBIRD_WRITES=0" in install_common_text \ No newline at end of file + assert "FIREBIRD_WRITES=0" in install_common_text