fix: persist and recover the Agent's real installation_id (heartbeat activation)

POST /v1/agents/enroll now returns installation_id; new installs persist it
to agent.env immediately. Runtime recovery precedence: existing SQLite
installation_identity -> configured INSTALLATION_ID -> authenticated
GET /v1/agents/me/identity. Recovery fails closed on missing/malformed/
mismatched identity rather than inventing a UUID (which the Hub would then
reject as a conflicting Installation on an already-enrolled Agent). An
explicit AGENT_JOBS_ENABLED gate stops the service from claiming a job right
after the first heartbeat.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
gti
2026-09-23 22:13:56 -03:00
co-authored by Claude Sonnet 5
parent a9aded1e49
commit ac7b503c2f
16 changed files with 754 additions and 15 deletions
+28 -1
View File
@@ -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:
+9 -1
View File
@@ -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__":
+1
View File
@@ -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
+24 -5
View File
@@ -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()
+34
View File
@@ -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"}
+122 -3
View File
@@ -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
+36
View File
@@ -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")
+58
View File
@@ -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]
+1
View File
@@ -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()
+20
View File
@@ -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
@@ -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
assert "INSTALLATION_CODE_CREATED" in events and "INSTALLATION_CODE_REVOKED" in events and "INSTALLATION_REVOKED" in events
+58 -1
View File
@@ -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"}
@@ -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.
@@ -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.
+2 -1
View File
@@ -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
return
+3 -2
View File
@@ -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
assert "FIREBIRD_WRITES=0" in install_common_text