diff --git a/plugins/computers/frontend/views/PCDetail.vue b/plugins/computers/frontend/views/PCDetail.vue
index ade30e40..a958a62e 100644
--- a/plugins/computers/frontend/views/PCDetail.vue
+++ b/plugins/computers/frontend/views/PCDetail.vue
@@ -695,6 +695,15 @@
again. Previously collected logs are still readable below.
+
+
{{ logCollectorVersionNotice }}
+
+
+
+ Bulk Agent Update utility coming soon. Until then, agents update through
+ the existing installer push, and an endpoint behind the published build is
+ flagged on its own detail page.
+
+
@@ -194,6 +204,9 @@ const toast = useToast()
// Gated in the UI for convenience only; every enqueue is re-checked server-side.
const cancommand = computed(() => authStore.hasPermission('endpoints.command'))
const canrestart = computed(() => authStore.hasPermission('endpoints.restart'))
+// Reserved for the staged rollout. Agent replacement is the control plane
+// modifying itself, so it stays separate from ordinary app deployment.
+const canagentupdate = computed(() => authStore.hasPermission('endpoints.agentupdate'))
const allselected = computed(() =>
endpoints.value.length > 0 && selected.value.length === endpoints.value.length)
@@ -515,6 +528,18 @@ function changePerPage(newPerPage) {
.bulk-count { font-size: 0.9rem; margin-right: 0.25rem; }
+/* Deliberately quieter than .bulk-bar: this announces something that does not
+ exist yet, so it must not compete with controls that actually do work. */
+.rollout-note {
+ padding: 0.6rem 0.9rem;
+ margin-bottom: 0.75rem;
+ font-size: 0.9rem;
+ color: var(--text-light);
+ background: var(--bg-card);
+ border: 1px dashed var(--border);
+ border-radius: 0.25rem;
+}
+
.select-cell { width: 1%; }
diff --git a/shopdb/api/__init__.py b/shopdb/api/__init__.py
index 7fd1830e..84fb8fad 100644
--- a/shopdb/api/__init__.py
+++ b/shopdb/api/__init__.py
@@ -99,6 +99,25 @@ def collector_authorized(pluginname: str):
from shopdb.core.api.collector import _check_collector_auth, _plugin_api_key
return _check_collector_auth(_plugin_api_key(pluginname), pluginname)
+
+def application_package_location():
+ """Where application installers live, and the URL prefix that serves them.
+
+ Returns (directory, urlprefix). A plugin that registers an installable
+ payload (the endpoint agent bundle, for example) must put the file exactly
+ where the applications API expects it and build installpath the same way,
+ or update_app cannot resolve the download. Reconstructing either by hand
+ lets the two drift silently, so both come from one place.
+
+ Imported lazily for the same reason as collector_authorized above:
+ shopdb.core.api imports back into shopdb.api, so a module-level import
+ would be circular at load time.
+ """
+ from shopdb.core.api.applications import (
+ _apppackage_dir, APP_PACKAGE_URL_PREFIX)
+ return _apppackage_dir(), APP_PACKAGE_URL_PREFIX
+
+
# Serving an uploaded file safely. A plugin that serves user-supplied bytes must
# not have to remember the headers that keep an SVG or an HTML file from running
# as script in this origin - see shopdb/utils/uploads.py.
@@ -388,6 +407,7 @@ __all__ = [
'list_service_tokens',
'revoke_service_tokens',
'collector_authorized',
+ 'application_package_location',
'SupportTeam',
# Serving uploads
'send_upload',
diff --git a/tests/test_plugins/test_endpoints_agent_registration.py b/tests/test_plugins/test_endpoints_agent_registration.py
new file mode 100644
index 00000000..29898601
--- /dev/null
+++ b/tests/test_plugins/test_endpoints_agent_registration.py
@@ -0,0 +1,250 @@
+"""Auto-registration of staged agent builds as a pushable Application.
+
+The endpoint update_app push resolves an install source from the applications
+table alone; it cannot see the agent version store. Before this, a build could
+be staged and current while no application row existed at all, which is exactly
+what was found in the fleet - the agent was unpushable.
+
+These tests pin the properties that make the bridge trustworthy:
+ - staging creates the row, and re-staging updates it in place (never a row
+ per build, because appname is unique and drift is the failure mode);
+ - the packaged archive is byte-stable for unchanged input, because update_app
+ pins a download to a digest and a drifting hash breaks verification;
+ - every path that changes 'current' re-registers, including rollback;
+ - registration failure never fails an upload that already landed on disk.
+
+Uploads use PE-magic bytes with a monkeypatched version parser, matching the
+approach in test_endpoints_agentupload.py - building a real PE with an
+RT_VERSION resource inline is impractical.
+
+Every test runs against an ISOLATED store. create_app('testing') resolves
+instance_path to the real repo instance/ directory, so without redirection
+these tests stage fake 130-byte binaries into the developer's actual agent
+store and leave invented versions behind in its manifest. The isolate fixture
+below is autouse for that reason: it is a correctness requirement, not a
+convenience.
+"""
+
+import hashlib
+import io
+import os
+import zipfile
+
+import pytest
+
+from plugins.endpoints.api import agentstore, agentupload, agentregistry
+from shopdb.core.models.application import Application
+
+UPLOAD_URL = '/api/endpoints/siteagent/binaries/{}'
+CURRENT_URL = '/api/endpoints/siteagent/binaries/{}/current'
+
+_PE_BYTES = b'MZ' + b'\x00' * 128
+
+
+@pytest.fixture(autouse=True)
+def isolate_store(tmp_path, monkeypatch):
+ """Point the agent store and package output at a per-test directory.
+
+ Both must be redirected. Redirecting only the store would still write the
+ ~zip into the real applicationpackages tree under a live application id.
+ """
+ siteagent = tmp_path / 'siteagent'
+ packages = tmp_path / 'applicationpackages'
+ siteagent.mkdir()
+ packages.mkdir()
+ monkeypatch.setattr(agentstore, '_siteagent_dir', lambda: str(siteagent))
+ monkeypatch.setattr(agentregistry, 'application_package_location',
+ lambda: (str(packages), '/api/applications/package/'))
+ return tmp_path
+
+
+def _upload(client, headers, monkeypatch, version, binkey='agent',
+ payload=None, makecurrent=None):
+ """Stage a build through the real HTTP route."""
+ monkeypatch.setattr(agentstore, 'read_pe_file_version', lambda data: version)
+ form = {'file': (io.BytesIO(payload or _PE_BYTES), 'shopdbagent.exe'),
+ 'version': version}
+ if makecurrent is not None:
+ form['makecurrent'] = makecurrent
+ return client.post(UPLOAD_URL.format(binkey), data=form,
+ content_type='multipart/form-data', headers=headers)
+
+
+def _agent_row():
+ return Application.query.filter_by(
+ appname=agentregistry.AGENT_APPNAME).first()
+
+
+def test_staging_a_build_creates_a_pushable_application_row(
+ client, db, app, auth_headers, monkeypatch):
+ """Without this row, update_app has nothing to resolve and cannot push."""
+ assert _agent_row() is None
+
+ response = _upload(client, auth_headers, monkeypatch, '1.16.0.0')
+ assert response.status_code == 200
+
+ registration = response.get_json()['data']['registration']
+ assert registration['ok'] is True
+
+ row = _agent_row()
+ assert row is not None
+ # The row must be resolvable and pinned: a path update_app can fetch, and a
+ # digest it can verify the bytes against.
+ assert row.installpath == '/api/applications/package/{}/{}'.format(
+ row.appid, agentregistry.AGENT_PACKAGE_FILENAME)
+ assert row.installersha256 == registration['sha256']
+ assert row.isinstallable is True
+ # Hidden: this is machine-managed, and must not appear in the software
+ # catalogue beside real licensed applications.
+ assert row.ishidden is True
+
+
+def test_restaging_updates_the_same_row_rather_than_adding_one(
+ client, db, app, auth_headers, monkeypatch):
+ """appname is unique; a row per build would break the push target."""
+ _upload(client, auth_headers, monkeypatch, '1.16.0.0')
+ first = _agent_row()
+ firstid = first.appid
+
+ response = _upload(client, auth_headers, monkeypatch, '1.17.0.0')
+ assert response.status_code == 200
+
+ assert Application.query.filter_by(
+ appname=agentregistry.AGENT_APPNAME).count() == 1
+ row = _agent_row()
+ assert row.appid == firstid
+ # The row must follow the newly current build, not keep pointing at 1.16.
+ assert row.installersha256 == \
+ response.get_json()['data']['registration']['sha256']
+
+
+def test_packaging_the_same_build_twice_yields_the_same_digest(
+ client, db, app, auth_headers, monkeypatch):
+ """update_app pins bytes by digest.
+
+ zipfile stamps members with the current mtime by default, which made the
+ archive hash change on every call for identical input. That would rewrite
+ installersha256 out from under an endpoint mid-download and fail its
+ SHA-256 verification.
+ """
+ _upload(client, auth_headers, monkeypatch, '1.16.0.0')
+
+ firstpayload, firstmanifest = agentstore.build_current_package()
+ secondpayload, secondmanifest = agentstore.build_current_package()
+
+ assert firstmanifest['sha256'] == secondmanifest['sha256']
+ assert firstpayload == secondpayload
+
+
+def test_the_package_contains_the_staged_binary_under_a_fixed_name(
+ client, db, app, auth_headers, monkeypatch):
+ """The install script addresses members by name, not by discovery."""
+ _upload(client, auth_headers, monkeypatch, '1.16.0.0')
+
+ payload, manifest = agentstore.build_current_package()
+ with zipfile.ZipFile(io.BytesIO(payload)) as archive:
+ assert archive.testzip() is None
+ names = archive.namelist()
+ assert agentstore.PACKAGE_MEMBER_NAMES['agent'] in names
+ assert manifest['agentversion'] == '1.16.0.0'
+ assert manifest['contents']['agent']['version'] == '1.16.0.0'
+
+
+def test_the_written_package_file_matches_the_recorded_digest(
+ client, db, app, auth_headers, monkeypatch):
+ """A row whose digest disagrees with the file on disk is unpushable."""
+ _upload(client, auth_headers, monkeypatch, '1.16.0.0')
+ row = _agent_row()
+
+ path = agentregistry._package_path(row.appid)
+ assert os.path.isfile(path)
+ with open(path, 'rb') as handle:
+ ondisk = hashlib.sha256(handle.read()).hexdigest()
+ assert ondisk == row.installersha256
+ # The atomic-write temp file must not survive a successful write.
+ assert not os.path.isfile(path + '.tmp')
+
+
+def test_rolling_back_repoints_the_row_at_the_restored_build(
+ client, db, app, auth_headers, monkeypatch):
+ """A rollback changes the bytes the fleet should get.
+
+ If only upload re-registered, the row would still pin the build the
+ operator just rolled away from, and the push would ship it anyway.
+ """
+ _upload(client, auth_headers, monkeypatch, '1.16.0.0',
+ payload=b'MZ' + b'\x01' * 256)
+ oldsha = _agent_row().installersha256
+
+ _upload(client, auth_headers, monkeypatch, '1.17.0.0',
+ payload=b'MZ' + b'\x02' * 256)
+ newsha = _agent_row().installersha256
+ assert newsha != oldsha
+
+ response = client.put(CURRENT_URL.format('agent'),
+ json={'version': '1.16.0.0'}, headers=auth_headers)
+ assert response.status_code == 200
+ assert response.get_json()['data']['registration']['ok'] is True
+
+ assert agentstore.current_version('agent') == '1.16.0.0'
+ # Back to the rolled-to build's package, not the newer one.
+ assert _agent_row().installersha256 == oldsha
+
+
+def test_a_registration_failure_does_not_fail_the_upload(
+ client, db, app, auth_headers, monkeypatch):
+ """The binary is already staged and audited by the time this runs.
+
+ Turning a packaging problem into an error response would tell the operator
+ the upload was rejected when it actually succeeded, inviting a re-upload of
+ a build that is already there.
+ """
+ def _explode():
+ raise OSError('simulated packaging failure')
+
+ monkeypatch.setattr(agentregistry.agentstore, 'build_current_package',
+ _explode)
+
+ response = _upload(client, auth_headers, monkeypatch, '1.16.0.0')
+
+ assert response.status_code == 200
+ body = response.get_json()['data']
+ # The staging itself still stands...
+ assert body['current'] == '1.16.0.0'
+ # ...and the failure is surfaced rather than swallowed.
+ assert body['registration']['ok'] is False
+ assert 'simulated packaging failure' in body['registration']['error']
+
+
+def test_staging_only_a_tray_reports_that_there_is_nothing_to_package(
+ client, db, app, auth_headers, monkeypatch):
+ """The bundle is defined by the agent; a tray alone is not pushable."""
+ response = _upload(client, auth_headers, monkeypatch, '1.6.5.0',
+ binkey='tray')
+
+ assert response.status_code == 200
+ registration = response.get_json()['data']['registration']
+ assert registration['ok'] is False
+ assert 'no current agent build' in registration['error']
+ assert _agent_row() is None
+
+
+def test_these_tests_do_not_touch_the_real_agent_store(
+ client, db, app, auth_headers, monkeypatch, isolate_store):
+ """Guard the isolation itself.
+
+ An earlier draft of this file wrote fake 130-byte binaries into the repo's
+ real instance/siteagent/ tree and invented a version in its manifest,
+ because create_app('testing') shares the developer's instance_path. If that
+ redirection ever regresses, fail here rather than silently corrupting a
+ staged build again.
+ """
+ _upload(client, auth_headers, monkeypatch, '9.9.9.9')
+
+ # The staged build landed inside the per-test directory...
+ staged = isolate_store / 'siteagent' / 'agent' / '9.9.9.9'
+ assert staged.is_dir()
+
+ # ...and the real store never saw it.
+ realstore = os.path.join(app.instance_path, 'siteagent', 'agent', '9.9.9.9')
+ assert not os.path.exists(realstore)
diff --git a/tests/test_plugins/test_endpoints_agent_version_guard.py b/tests/test_plugins/test_endpoints_agent_version_guard.py
new file mode 100644
index 00000000..110949a0
--- /dev/null
+++ b/tests/test_plugins/test_endpoints_agent_version_guard.py
@@ -0,0 +1,158 @@
+"""The agent version guard on command enqueue.
+
+An agent older than an action has no case for it and falls through to its
+unknown-action default, answering "Unknown or disallowed action". That reads as
+an authorization refusal, so the operator goes looking at permissions they
+already hold - which is exactly what happened on a live endpoint running 1.15.0
+after collect_log shipped in 1.16.0.
+
+These tests pin the refusal at the SERVER, before the command is ever queued,
+and pin that the message names both versions so the answer is actionable.
+"""
+
+from werkzeug.security import generate_password_hash
+
+
+def _endpoint(db, hostname, agentversion):
+ """An Asset + Computer pair whose reported state carries an agent version."""
+ from shopdb.api import Asset, AssetType
+ from plugins.computers.models import Computer
+ from plugins.endpoints.models import EndpointState
+
+ assettype = AssetType.query.filter_by(assettype='computer').first()
+ if assettype is None:
+ assettype = AssetType(assettype='computer', pluginname='computers',
+ tablename='computers')
+ db.session.add(assettype)
+ db.session.flush()
+
+ asset = Asset(assetnumber=hostname, name=hostname,
+ assettypeid=assettype.assettypeid, isactive=True)
+ db.session.add(asset)
+ db.session.flush()
+ db.session.add(Computer(assetid=asset.assetid, hostname=hostname))
+ db.session.add(EndpointState(assetid=asset.assetid, hostname=hostname,
+ agentversion=agentversion))
+ db.session.commit()
+ return asset.assetid
+
+
+def _admin(db, client, username='guardadmin'):
+ """An admin, so permission and policy gates cannot be what refuses."""
+ from shopdb.core.models import User, Role, Permission
+
+ Permission.seed()
+ db.session.flush()
+ role = Role.query.filter_by(rolename='admin').first()
+ if role is None:
+ role = Role(rolename='admin')
+ db.session.add(role)
+ db.session.commit()
+
+ user = User(username=username, email=username + '@test.local',
+ passwordhash=generate_password_hash('testpass'))
+ user.roles.append(role)
+ db.session.add(user)
+ db.session.commit()
+
+ response = client.post('/api/auth/login',
+ json={'username': username, 'password': 'testpass'})
+ token = response.get_json()['data']['access_token']
+ return {'Authorization': f'Bearer {token}'}
+
+
+def _collect(client, headers, assetid):
+ return client.post(f'/api/endpoints/{assetid}/commands',
+ json={'action': 'collect_log',
+ 'target': r'C:\logs\app.log'},
+ headers=headers)
+
+
+def test_an_agent_below_the_minimum_is_refused_at_enqueue(app, client, db):
+ """The command must not be queued at all.
+
+ Queueing it would spend the endpoint's one in-flight command slot and leave
+ a failed row that reads as endpoint misbehaviour, when the truth is the
+ agent simply predates the feature.
+ """
+ from plugins.endpoints.models import EndpointCommand
+
+ assetid = _endpoint(db, 'oldagentbox', '1.15.0.0')
+ headers = _admin(db, client)
+
+ response = _collect(client, headers, assetid)
+
+ assert response.status_code == 400
+ message = response.get_json()['data']['error']['message']
+ # Both versions, or the operator cannot tell what to do about it.
+ assert '1.16.0' in message
+ assert '1.15.0.0' in message
+ assert EndpointCommand.query.filter_by(assetid=assetid).count() == 0
+
+
+def test_an_agent_at_the_minimum_is_allowed(app, client, db):
+ """The guard must not refuse a capable endpoint.
+
+ 1.16.0.0 against a 1.16.0 floor is the four-vs-three-part spelling of the
+ same build; a lexical compare would get this wrong.
+ """
+ from plugins.endpoints.models import EndpointCommand
+
+ assetid = _endpoint(db, 'newagentbox', '1.16.0.0')
+ headers = _admin(db, client)
+
+ response = _collect(client, headers, assetid)
+
+ assert response.status_code in (200, 201)
+ assert EndpointCommand.query.filter_by(assetid=assetid,
+ action='collect_log').count() == 1
+
+
+def test_a_lexically_larger_but_older_version_is_still_refused(app, client, db):
+ """'1.9.0.0' must not pass a '1.16.0' floor.
+
+ String comparison says '9' > '1' and lets it through. This is the specific
+ bug the numeric parse exists to prevent, so it gets its own test rather
+ than living only in the helper's unit coverage.
+ """
+ assetid = _endpoint(db, 'lexicaltrapbox', '1.9.0.0')
+ headers = _admin(db, client)
+
+ response = _collect(client, headers, assetid)
+
+ assert response.status_code == 400
+ assert '1.16.0' in response.get_json()['data']['error']['message']
+
+
+def test_an_unknown_agent_version_is_refused_rather_than_guessed(app, client, db):
+ """A placeholder version fails closed.
+
+ Seeded rows carry '0.0.0-sample'. There is no honest ordering between a
+ placeholder and a real build, and letting it through only moves the failure
+ to the agent where the error message is the misleading one.
+ """
+ assetid = _endpoint(db, 'samplebox', '0.0.0-sample')
+ headers = _admin(db, client)
+
+ response = _collect(client, headers, assetid)
+
+ assert response.status_code == 400
+
+
+def test_an_action_without_a_floor_is_unaffected(app, client, db):
+ """The guard is per-action, not global.
+
+ collect_now declares no minimum, so an old agent must still be able to run
+ it - otherwise adding a floor to one action quietly narrows the others.
+ """
+ from plugins.endpoints.models import EndpointCommand
+
+ assetid = _endpoint(db, 'refreshbox', '1.11.0.0')
+ headers = _admin(db, client)
+
+ response = client.post(f'/api/endpoints/{assetid}/commands',
+ json={'action': 'collect_now'}, headers=headers)
+
+ assert response.status_code in (200, 201)
+ assert EndpointCommand.query.filter_by(assetid=assetid,
+ action='collect_now').count() == 1
diff --git a/tests/test_plugins/test_endpoints_plugin.py b/tests/test_plugins/test_endpoints_plugin.py
index f6b90c2d..775d6bdf 100644
--- a/tests/test_plugins/test_endpoints_plugin.py
+++ b/tests/test_plugins/test_endpoints_plugin.py
@@ -11,7 +11,8 @@ from plugins.endpoints.plugin import EndpointsPlugin, CURATED_EVENT_IDS
from plugins.endpoints.eventcatalog import EVENT_CATALOG, CRITICAL_EVENT_IDS
from plugins.endpoints.commands import (
COMMAND_ALLOWLIST, is_allowed_action, elevated_permission_for,
- requires_target,
+ requires_target, minimum_agent_version_for, agent_meets_minimum,
+ parse_agent_version,
)
@@ -216,6 +217,61 @@ def test_collect_log_survives_a_policy_that_never_mentions_it():
assert elevated_permission_for('collect_log') == 'endpoints.updateapp'
+def test_collect_log_declares_the_agent_version_that_implements_it():
+ """collect_log names its minimum agent version.
+
+ An agent older than the action has no case for it and falls through to its
+ unknown-action default, answering "Unknown or disallowed action" - which
+ reads as an authorization refusal and sends the operator to check a grant
+ they already hold. The floor is what lets the server refuse first with the
+ actionable answer instead.
+ """
+ assert minimum_agent_version_for('collect_log') == '1.16.0'
+ # Actions that every shipped agent understands must NOT declare a floor,
+ # or the guard starts refusing endpoints that are perfectly capable.
+ assert minimum_agent_version_for('collect_now') is None
+ assert minimum_agent_version_for('system_restart') is None
+
+
+def test_agent_version_comparison_survives_real_fleet_version_strings():
+ """Version comparison is numeric per part, not lexical.
+
+ Two traps, both real in this fleet. Agents report four parts ('1.15.0.0')
+ while the csproj and binary store carry three ('1.16.0'), so the same build
+ must compare equal across both spellings. And a lexical compare ranks
+ '1.9.0.0' ABOVE '1.16.0' because '9' > '1' - which would silently let an
+ old agent past the guard, the exact failure the guard exists to prevent.
+ """
+ assert parse_agent_version('1.16.0') == parse_agent_version('1.16.0.0')
+ assert agent_meets_minimum('1.16.0', '1.16.0')
+ assert agent_meets_minimum('1.16.0.0', '1.16.0')
+ assert agent_meets_minimum('1.17.0.0', '1.16.0')
+ assert agent_meets_minimum('2.0.0.0', '1.16.0')
+ assert not agent_meets_minimum('1.15.0.0', '1.16.0')
+ assert not agent_meets_minimum('1.11.0.0', '1.16.0')
+ # The lexical trap.
+ assert not agent_meets_minimum('1.9.0.0', '1.16.0')
+
+
+def test_an_unreadable_agent_version_fails_the_guard_closed():
+ """An unparseable or absent version does not satisfy a minimum.
+
+ Eight endpoints in this fleet report '0.0.0-sample', and a never-checked-in
+ endpoint reports nothing at all. There is no honest ordering between a
+ placeholder and a real build, so the guard refuses rather than guessing.
+ Letting it through would only move the failure to the agent, where the
+ error message is the misleading one.
+ """
+ assert parse_agent_version('0.0.0-sample') is None
+ assert not agent_meets_minimum('0.0.0-sample', '1.16.0')
+ assert not agent_meets_minimum(None, '1.16.0')
+ assert not agent_meets_minimum('', '1.16.0')
+ # No declared floor means no opinion: an action every agent implements must
+ # stay available even to an endpoint whose version we cannot read.
+ assert agent_meets_minimum('0.0.0-sample', None)
+ assert agent_meets_minimum(None, None)
+
+
def test_run_script_and_update_app_are_allowlisted_delivery_actions():
"""The two software-delivery actions are allowlisted with a targetkind so
the enqueue route accepts them and carries their subject on the wire: