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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user