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.
This commit is contained in:
benjamin.baker
2026-09-09 22:56:21 -04:00
parent d32293ccb2
commit 8be1c2379f
8 changed files with 957 additions and 6 deletions

View File

@@ -218,7 +218,7 @@
:assetid="computer.assetid"
:accessmethods="computer.accessmethods || []"
>
<template #actions v-if="canRestart || canHoldPolicy">
<template #actions v-if="canRestart || canHoldPolicy || (canAgentUpdate && agentBehind)">
<!-- Admin only, and FIRST in the band. Placing a hold switches OFF
automatic policy enforcement, so it sits with the destructive
controls rather than with the read-only refresh. Red rather
@@ -233,6 +233,18 @@
<button v-if="canRestart" @click="confirmRestart"
class="btn btn-warning btn-sm"
:disabled="restarting">Restart Computer</button>
<!-- Only shown when this endpoint is actually behind the
published build, so it is a call to action rather than a
permanent button. Warning-styled: it interrupts the agent
service, but unlike Restart it does not take the machine
down, and it rolls back on failure. -->
<button v-if="canAgentUpdate && agentBehind"
@click="confirmAgentUpgrade"
class="btn btn-warning btn-sm"
:title="`Endpoint is on ${agentversion}, published build is ${agentcurrent}`"
:disabled="upgradingAgent">
{{ upgradingAgent ? 'Queueing...' : `Update Agent to ${agentcurrent}` }}
</button>
</template>
</AssetTriageBand>
</div>
@@ -1246,6 +1258,13 @@ const refreshing = ref(false)
// enforced again server-side; hiding the button is convenience, not the guard.
const canCommand = computed(() => authStore.hasPermission('endpoints.command'))
const canRestart = computed(() => authStore.hasPermission('endpoints.restart'))
// Replacing the agent is self-modifying the control plane, so it carries its
// own grant. The server also requires endpoints.runscript, because the upgrade
// lands as a script push - reflected here so the button does not offer an
// action the server will refuse.
const canAgentUpdate = computed(
() => authStore.hasPermission('endpoints.agentupdate')
&& authStore.hasPermission('endpoints.runscript'))
// Break-glass is admin-only and re-checked server-side against the CALLER, so a
// scoped token cannot inherit it from the admin who minted it. This flag only
@@ -1881,6 +1900,58 @@ const logCollectorVersionNotice = computed(() => {
+ `${reported}. Update the agent on this endpoint to use it.`
})
// True only when both versions parse AND the endpoint is strictly older than
// the published build. Silent when nothing is published or either version is a
// placeholder like '0.0.0-sample' - offering an upgrade off an unreadable
// version would be a false alarm, and the server applies the same rule.
const agentBehind = computed(() => {
if (!agentversion.value || !agentcurrent.value) return false
if (!parseAgentVersion(agentversion.value)) return false
if (!parseAgentVersion(agentcurrent.value)) return false
return !agentMeetsMinimum(agentversion.value, agentcurrent.value)
})
const upgradingAgent = ref(false)
// Typed confirmation, matching confirmRunScript and the destructive Corporate
// Tools: this stops the SYSTEM service that IS the command channel and swaps it
// underneath itself. An OK button is too easy to hit for something that, if it
// goes wrong, takes remote management of the machine with it. The prompt names
// BOTH versions so the operator can see exactly what is being replaced.
async function confirmAgentUpgrade() {
if (!computer.value?.assetid) return
const label = computer.value?.assetnumber || computer.value?.name || 'this PC'
const typed = prompt(`Update the ShopDB agent on ${label}?\n\n`
+ `${agentversion.value} will be replaced with ${agentcurrent.value}.\n`
+ 'The agent service stops and restarts, so this endpoint will briefly '
+ 'stop reporting. If the upgrade fails the agent rolls back to the '
+ 'current build.\n\nType UPDATE to confirm.')
if (typed !== 'UPDATE') return
upgradingAgent.value = true
try {
const response = await endpointsApi.enqueueAgentUpgrade(computer.value.assetid)
// "Queued", not "updated": the agent collects this on its next check-in and
// the swap happens afterwards. Claiming success here would assert an
// outcome nothing has observed.
toast.success(`Agent update queued for ${label}`)
const commandId = response?.data?.data?.endpointcommandid
if (commandId) {
await waitForCommand(commandId)
// Re-read liveness so the version and the behind flag reflect what the
// endpoint now reports, rather than leaving a stale "behind" badge on a
// machine that just upgraded.
await loadLiveness(computer.value.assetid)
await assetPanels.value?.refresh()
}
} catch (error) {
toast.error(apiError(error, `Failed to queue the agent update for ${label}`))
} finally {
upgradingAgent.value = false
}
}
// History for this endpoint. Metadata only - bodies are fetched one at a time,
// because 20 rows of multi-hundred-KB logs would make opening the tab far more
// expensive than reading the one log that is actually wanted.