Commit Graph

2 Commits

Author SHA1 Message Date
benjamin.baker
8be1c2379f Add one-click agent self-upgrade from the endpoint detail page
An operator could see that an endpoint was behind the published build but
had no way to act on it. update_app resolves against Application rows, and
the agent lives in the version store, so there was nothing to push.

Bridge the two: the staged build is packaged and registered as a reserved
Application row, and a managed PowerShell script is seeded to install it.
The endpoint downloads that package over the existing collector-authenticated
app-package route, so no parallel delivery path is introduced.

The new route prepares the delivery and then DELEGATES to
enqueue_endpoint_command as a digest-pinned run_script. It deliberately does
not implement its own enqueue: a second path would duplicate, and eventually
drift from, the endpoint lock, the Configuration Policy gate, stale-command
reaping and the at-most-one-in-flight rule. A fleet-wide agent swap is the
last action that should skip a quarantine check. Tests pin that delegation
rather than only the happy path.

Because it lands as run_script, the caller needs endpoints.runscript on top
of endpoints.agentupdate. That is intended: this delivers arbitrary SYSTEM
PowerShell by the same mechanism as any other script push. The audit event
is written only on an actual 201, so a refused push does not record an
upgrade that never happened.

Replacing the agent is self-modifying the control plane - the process
receiving the command is the one being swapped. The script therefore
re-launches itself detached and exits immediately, because the agent runs
run_script as a direct child and holds its stdout/stderr pipes while
waiting on it. Expect the command to report queued rather than success:
the process that would report completion is stopped mid-run. Judge the
result by the version the endpoint reports afterward.

Tray relaunch does not use the installer's `explorer.exe <path>` trick.
That works in install-shopdbagent.ps1 only because the installer runs as
the user - note its own `-ne "SYSTEM"` guard. This script always runs as
SYSTEM in session 0, where the hand-off reaches no shell and exits 0, so
Start-Process reported success for a relaunch that never happened. It also
cannot descend into the session with a token-duplicating launch:
UserContextSpool documents that WTSQueryUserToken is blocked by endpoint
protection on these hosts. Instead a one-shot scheduled task targets the
pinned ShopFloor account, matching how the user-context spool already
resolves that user, and the tray must run as that account or the
mapped-drive probe reports the wrong (empty) drive table. The relaunch is
verified by polling for the process rather than assumed, and stays
best-effort: the Run key still starts the tray at next logon, and a late
tray is not worth rolling back a good agent upgrade.

panel_status now carries agentbehind/agentversion/agentcurrent so the UI
gates the action on a flag rather than parsing "(behind X)" out of a label.
The button is confirmed by typing UPDATE, naming both versions and the
service interruption, matching how run_script and system_restart are gated.

Verified on live endpoints: upgraded 1.13.0.0 and 1.15.0.0 machines to
1.16.0.0. run_script delivery, digest fetch and system context all predate
1.13.0.0, so no minimum agent version is declared for this path.
2026-09-09 22:56:21 -04:00
benjamin.baker
d32293ccb2 Auto-register staged agent builds as a pushable application
Gate collect_log on a minimum agent version and bridge the agent version
store to the applications table so a staged build can actually be pushed.

update_app resolves an install source from the applications table alone; it
cannot see agentstore. A build could therefore be staged and current while no
application row existed at all, which is what the fleet was in: the agent was
unpushable. Staging is now the single source of truth - it packages the
current build and upserts the row in the same request, so the row cannot
describe a build that is no longer staged.

PARTIAL BUILD ADVISORY
This is steps 1-4 of a longer rollout and is deliberately incomplete:
  - There is no push trigger yet. The registered package is resolvable and
	verifiable, but nothing enqueues an upgrade; the "behind current build"
	indicator is still display-only.
  - The upgrade template script, its script row, and the enqueue route
	(endpoints.agentupdate) are not written.
  - Blocking constraint for the next slice: run_script executes
	powershell.exe -File <script> with NO argument channel, so a delivered
	upgrade script cannot be passed BaseUrl/CollectorKey and must either
	source them itself or run_script must be extended first.
Nothing here touches a live endpoint. Staging only changes what a future
push would contain.

Details:
  - Package members carry a fixed timestamp. zipfile stamps the current mtime
	by default, so identical input produced a different archive digest on
	every call. update_app pins a download by SHA-256, so a drifting hash
	would rewrite installersha256 out from under an endpoint mid-download and
	fail its verification.
  - All three routes that can change 'current' re-register (upload,
	adopt-legacy, rollback). Covering only upload would leave the row pinned
	to the build an operator just rolled away from, and the push would ship
	it anyway - silent drift.
  - Registration cannot fail an upload. The binary is staged and audited by
	the time it runs, so a packaging problem is reported in the response
	payload rather than raised; returning an error would tell the operator a
	successful upload was rejected.
  - The package is written to a temp name and os.replace'd, so an interrupted
	write cannot leave a truncated archive at the path the row advertises.
  - The row is isinstallable but hidden: machine-managed, and it must not
	appear in the software catalogue beside real licensed applications.
  - Added application_package_location() to the shopdb.api contract surface
	(lazy, matching collector_authorized) returning the package directory and
	URL prefix together. agentregistry reached into shopdb.core internals,
	violating the plugin import contract; sourcing both from one place also
	stops the write location and installpath from drifting apart.

collect_log is refused at enqueue below its minimum agent version, with
numeric comparison - string ordering misplaces 1.9.0.0 against 1.16.0. The
refusal is mirrored in the UI so a command slot is not spent on a call that
is guaranteed to fail.

Test isolation: create_app('testing') resolves instance_path to the real repo
instance/ directory, so agent upload tests write into the developer's actual
store. The new tests redirect both the store and package output into tmp_path
and include a guard test that fails if that isolation regresses. NOTE: the
pre-existing test_endpoints_agentupload.py still has this leak and deposits
1.5.0.0 and 1.6.3 into instance/siteagent/ on a full run - not fixed here.

Suite: 2834 passed, 11 skipped. The two failures (test_docs_publishable, and
a symlink scan needing Windows privileges) were confirmed pre-existing by
stashing this work and reproducing them.
2026-09-09 21:40:51 -04:00