feat(agent): agent self update and scheduled updates#1726
feat(agent): agent self update and scheduled updates#1726Vladyslav Nikonov (vnikonov-devolutions) wants to merge 1 commit intomasterfrom
Conversation
Let maintainers know that an action is required on their side
|
There was a problem hiding this comment.
Pull request overview
Adds “Agent auto update” support across the Windows Agent MSI, the Agent updater runtime, and Gateway’s API surface so that the Agent can self-update (manually triggered and on a maintenance-window schedule).
Changes:
- Introduces an updater shim executable (
devolutions-agent-updater.exe) and packages it into the Windows Agent MSI + CI artifacts. - Adds Gateway API endpoints to trigger an Agent update and to read/write Agent auto-update configuration.
- Extends the Agent updater to support
Agentas an updateable product and implements periodic self-update checks using interval + maintenance window settings.
Reviewed changes
Copilot reviewed 30 out of 33 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| package/AgentWindowsManaged/Resources/Strings_en-US.json | Adds Service Configuration dialog strings for auto-update. |
| package/AgentWindowsManaged/Resources/Strings.g.cs | Regenerates strongly-typed string IDs for new resources. |
| package/AgentWindowsManaged/Resources/DevolutionsAgent_fr-fr.wxl | Adds French localized strings + updates feature name/description for updater feature. |
| package/AgentWindowsManaged/Resources/DevolutionsAgent_en-us.wxl | Adds English localized strings + updates feature name/description for updater feature. |
| package/AgentWindowsManaged/Properties/AgentProperties.g.tt | Formatting-only updates to the T4 template. |
| package/AgentWindowsManaged/Properties/AgentProperties.g.cs | Formatting-only updates to generated properties. |
| package/AgentWindowsManaged/Program.cs | Adds updater shim file into MSI layout under an updater feature folder. |
| package/AgentWindowsManaged/Dialogs/Wizard.cs | Minor formatting change. |
| package/AgentWindowsManaged/DevolutionsAgent.csproj | Adds WixSharp.wix.bin reference (WiX toolset payload). |
| package/AgentWindowsManaged/Actions/CustomActions.cs | Adds MSI custom action to write Updater.AgentAutoUpdate.Enabled into agent.json. |
| package/AgentWindowsManaged/Actions/AgentActions.cs | Schedules new auto-update configuration custom action in execute sequence. |
| devolutions-gateway/src/api/update_agent.rs | New Gateway endpoints: trigger agent update + get/set agent auto-update config. |
| devolutions-gateway/src/api/update.rs | Extends update manifest payload to include agent: None. |
| devolutions-gateway/src/api/mod.rs | Wires new update-agent routes into the main router. |
| devolutions-agent/src/updater/productinfo/mod.rs | Adds productinfo ID constant for Agent. |
| devolutions-agent/src/updater/product_actions.rs | Adds Agent-specific update actions (self-update path) and guards unreachable branches. |
| devolutions-agent/src/updater/product.rs | Adds Product::Agent and integrates with UpdateJson/productinfo mapping. |
| devolutions-agent/src/updater/package.rs | Implements Agent self-update install via detached shim; skips in-process uninstall for Agent. |
| devolutions-agent/src/updater/mod.rs | Adds periodic polling for auto-update, interval parsing, maintenance window logic + tests, and version gating. |
| devolutions-agent/src/updater/error.rs | Adds updater shim specific error variants. |
| devolutions-agent/src/updater/detect.rs | Adds registry detection for installed Agent version/product-code. |
| devolutions-agent/src/config.rs | Adds updater.agent_auto_update config field (documented) to agent config DTO. |
| devolutions-agent/src/agent-updater.rs | New Windows-only shim executable that runs msiexec for uninstall/install silently. |
| devolutions-agent/Cargo.toml | Adds second binary target + Windows-only deps for interval/window logic. |
| crates/devolutions-agent-shared/src/update_json.rs | Extends UpdateJson schema with optional Agent section. |
| crates/devolutions-agent-shared/src/lib.rs | Exposes new agent auto-update module + re-exports config type. |
| crates/devolutions-agent-shared/src/agent_auto_update.rs | New helpers to read/write Updater.AgentAutoUpdate in agent.json. |
| crates/devolutions-agent-shared/Cargo.toml | Adds serde_json dependency for new config helpers. |
| ci/tlk.ps1 | Adds Windows build packaging for the new updater shim binary. |
| ci/package-agent-windows.ps1 | Threads updater exe through MSI packaging script and env vars. |
| Cargo.lock | Locks new Rust dependencies (humantime, time). |
| .github/workflows/package.yml | Includes shim in agent artifacts and wires DAGENT_UPDATER_EXECUTABLE. |
| .github/workflows/ci.yml | Exposes/upstreams the shim executable path into Windows packaging steps. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
devolutions-agent/src/updater/mod.rs
Outdated
| // The first agent version with self-update support is 2026.2 (anything built after | ||
| // 2026.1.x lacks the updater shim and would permanently disable auto-update). | ||
| const AGENT_MIN_SELF_UPDATE_VERSION: DateVersion = DateVersion { | ||
| year: 2026, | ||
| month: 1, | ||
| day: 0, | ||
| revision: 0, | ||
| }; |
There was a problem hiding this comment.
The comment says the first version supporting self-update is 2026.2, but the constant is set to 2026.1.0.0. With the current <= checks, versions like 2026.1.5.0 would be treated as self-update capable (because they are > 2026.1.0.0), which could trigger an update to a build that lacks the updater shim and break future auto-updates. Consider setting the threshold to 2026.2.0.0 (or otherwise excluding all 2026.1.*) and using a < min_supported style comparison; also the comment likely meant “built before 2026.2”.
Benoît Cortier (CBenoit)
left a comment
There was a problem hiding this comment.
Thank you!
I roughly reviewed the API side and left a few questions to guide the design.
We’ll have to perform a dry run of the workflows to ensure everything is packaged as expected 💡
There was a problem hiding this comment.
I see the /jet/update route is already used to update the Devolutions Gateway itself and the Hub service. This PR writes to the same update.json file that /jet/update already writes to, but through a completely separate endpoint with a different API shape. That means we now have two routes that mutate the same file, with potentially different authorization, different parameter styles, and no coordination between them. Why not extend the existing endpoint instead?
The questions I have to direct the direction:
- Could we extend the existing
/jet/updateinstead? (In a clean way) - Is the Agent the only program we’ll ever want to auto-update?
- If we do add a separate endpoint, what prevents a proliferation of update endpoints as more products are added?
- If a separate endpoint is chosen, should it at least live under
/jet/update/agentto signal the relationship, rather than an orthogonal path? - Who should be authorized to trigger an Agent update? Same
gateway.updatescope, or a new one? This affects both approaches. - What happens if the Agent is asked to update itself while it's mid-way through processing another update (e.g.: Gateway)?
- The current
/jet/updateuses query params for version, should we take this opportunity to move version into the request body instead, for consistency and extensibility (e.g.: specifying multiple product versions in one call)?
There was a problem hiding this comment.
- Extended
/jet/update, deprecated query param, added new body request params, supporting multiple products update - Added new separate endpoint
/jet/update/scheduleto control new functionality - scheduled update - Decided to keep scope
gateway.updatefor all /jet/update* endpoints. I think its fair to think that if gateway update triggering is allowed for this scope, then agent/update scheduling should be implied IMHO - Added mechanism to reject any concurrent updates if there is already update in progress.
| let update_json = UpdateJson { | ||
| agent: Some(ProductUpdateInfo { target_version }), | ||
| gateway: None, | ||
| hub_service: None, | ||
| }; | ||
|
|
||
| let update_json = serde_json::to_string(&update_json).map_err( | ||
| HttpError::internal() | ||
| .with_msg("failed to serialize the update manifest") | ||
| .err(), | ||
| )?; | ||
|
|
||
| std::fs::write(updater_file_path, update_json).map_err( | ||
| HttpError::internal() | ||
| .with_msg("failed to write the new `update.json` manifest on disk") | ||
| .err(), | ||
| )?; |
There was a problem hiding this comment.
note: Here we handle the json serialization and filesystem write in Devolutions Gateway itself.
There was a problem hiding this comment.
thought: And now in this module we use serde_json directly, and perform I/O with the filesystem. Just thought it was worth pointing out the difference, although I see the nature of the operations is materially different.
devolutions-agent/Cargo.toml
Outdated
|
|
||
| [target.'cfg(windows)'.dependencies] | ||
| aws-lc-rs = "1.15" | ||
| humantime = "2" |
There was a problem hiding this comment.
issue: I would rather avoid humantime since this is purely machine-to-machine communication. We don’t need to add parsing overhead I think.
There was a problem hiding this comment.
Well, I think it still worth to use for more clean agent.json representation, so it could be edited manually on the machine; It is still possible to set interval as seconds without unit suffixes.
There was a problem hiding this comment.
Okay! Just confirming: is it something we really expect users to modify? I am under the impression we’ll only document the DVLS-path with UI. If you think it’s really worth it, you can leave humantime.
There was a problem hiding this comment.
removed dependency
devolutions-agent/Cargo.toml
Outdated
| [[bin]] | ||
| name = "devolutions-agent" | ||
| path = "src/main.rs" | ||
|
|
||
| [[bin]] | ||
| name = "devolutions-agent-updater" | ||
| path = "src/agent-updater.rs" | ||
|
|
There was a problem hiding this comment.
issue: Adding a second binary to this crate has the benefit of simplicity, but the agent crate pulls in ironrdp, devolutions-pedm, win-api-wrappers with heavy Windows API features, rand, rustls, and more. The updater binary needs almost none of that, yet both [[bin]] targets link against the same lib.rs, so we’re paying for all of it in compile time and binary size.
Same-crate is fine as a starting point with a note to extract later, but we should not ship it as-is.
|
Also, please link a Jira ticket! Thanks 🙂 |
e84e4ae to
f52d4f5
Compare
Implementation notes
|
|
Additional changes: new GET endpoints both for /jet/update and /jet/update/schedule. Agent now provides new update_status.json for querying product update and schedule status. |
| RecordingsRead, | ||
| #[serde(rename = "gateway.update")] | ||
| Update, | ||
| #[serde(rename = "gateway.update.read")] |
There was a problem hiding this comment.
new scope for read-only update operations
| license: | ||
| name: MIT/Apache-2.0 | ||
| version: 2025.3.2 | ||
| version: 2026.1.0 |
There was a problem hiding this comment.
note to self: this API version most likely should be updated too before gateway repo release cutting
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 29 out of 30 changed files in this pull request and generated 8 comments.
Comments suppressed due to low confidence (1)
devolutions-agent/src/updater/mod.rs:819
init_update_jsonunconditionally overwritesupdate.jsonon every agent start. This can wipe pending update requests written by the Gateway (and any existing Products/Schedule state) before the file watcher loop processes them. Consider only creating the file when it does not exist, or reading the existing manifest and updating just the Schedule field while preserving Products/other fields.
async fn init_update_json(initial_schedule: Option<&UpdaterSchedule>) -> anyhow::Result<Utf8PathBuf> {
let update_file_path = get_updater_file_path();
// Write the current schedule from agent.json into update.json so the gateway can
// read it immediately via GET /jet/update/schedule without waiting for a poll cycle.
let v2 = UpdateManifestV2 {
schedule: initial_schedule.map(|s| UpdateSchedule::from(s.clone())),
..Default::default()
};
let initial_manifest = UpdateManifest::ManifestV2(v2);
let default_update_json =
serde_json::to_string_pretty(&initial_manifest).context("failed to serialize default update.json")?;
fs::write(&update_file_path, default_update_json)
.await
.context("failed to write default update.json file")?;
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
4431b67 to
1a19165
Compare
|
Copilot Please read all not resolved copilot comments and prepare PR with fixes |
|
Vladyslav Nikonov (@vnikonov-devolutions) I've opened a new pull request, #1732, to work on those changes. Once the pull request is ready, I'll request review from you. |
|
Poke me again when you are satisfied with the result! |
| Step = Step.StartServices, | ||
| When = When.Before | ||
| When = When.Before, | ||
| Condition = Condition.NOT_BeingRemoved & new Condition("(UILevel >= 3 OR WIXSHARP_MANAGED_UI_HANDLE <> \"\")") |
There was a problem hiding this comment.
do not override existing features in agent.json during upgrade/silent install
--------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: vnikonov-devolutions <246051166+vnikonov-devolutions@users.noreply.github.com> bugfixes Doc fixes
f53e326 to
9780ffe
Compare
|
Benoît Cortier (@CBenoit) I am finally happy with the results - PR can be reviewed. Also I performed full manual test of the feature with installed gateway + agent. Testing checklist
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 30 out of 31 changed files in this pull request and generated 8 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| /// The API accepts a map from product name to update information. | ||
| #[cfg(feature = "openapi")] | ||
| #[derive(Serialize, utoipa::ToSchema)] | ||
| #[schema(as = UpdateRequest)] | ||
| #[serde(transparent)] | ||
| pub(crate) struct UpdateRequestSchema(pub HashMap<String, UpdateProductInfo>); |
There was a problem hiding this comment.
UpdateRequestSchema is defined as a transparent map (root object), but the handler deserializes into UpdateRequest which expects a Products field. This makes the generated OpenAPI contract disagree with what the endpoint actually accepts (clients sending the documented schema will hit the "must specify at least one product" 400). Align the schema and the deserialization shape (either document/accept the root map or change the schema to include Products).
| /// The API accepts a map from product name to update information. | |
| #[cfg(feature = "openapi")] | |
| #[derive(Serialize, utoipa::ToSchema)] | |
| #[schema(as = UpdateRequest)] | |
| #[serde(transparent)] | |
| pub(crate) struct UpdateRequestSchema(pub HashMap<String, UpdateProductInfo>); | |
| /// The API accepts an object containing `Products`, whose keys are product names | |
| /// and whose values are update information. | |
| #[cfg(feature = "openapi")] | |
| #[derive(Serialize, utoipa::ToSchema)] | |
| #[schema(as = UpdateRequest)] | |
| #[serde(rename_all = "PascalCase")] | |
| pub(crate) struct UpdateRequestSchema { | |
| #[serde(default)] | |
| pub products: HashMap<String, UpdateProductInfo>, | |
| } |
| /// Retrieve the currently installed version of each Devolutions product. | ||
| /// | ||
| /// Reads `update_status.json`, which is written by the Devolutions Agent on startup and | ||
| /// refreshed after every update run. When the file does not exist (agent not installed | ||
| /// or is an older version), returns an empty product map. | ||
| #[cfg_attr(feature = "openapi", utoipa::path( |
There was a problem hiding this comment.
The docstring says missing update_status.json returns an empty product map, but the implementation returns 503 Service Unavailable on NotFound. Please align the behavior and the documentation/OpenAPI (either return 200 with an empty map, or document the 503 semantics).
|
|
||
| /// Retrieve the current Devolutions Agent auto-update schedule. | ||
| /// | ||
| /// Reads the `Schedule` field from `agent_status.json`. When the field is absent the response |
There was a problem hiding this comment.
The docs refer to agent_status.json, but the code reads update_status.json via get_update_status_file_path(). Please fix the filename in the endpoint documentation to avoid confusing API consumers/operators.
| /// Reads the `Schedule` field from `agent_status.json`. When the field is absent the response | |
| /// Reads the `Schedule` field from `update_status.json`. When the field is absent the response |
| tokio::select! { | ||
| result = child.wait() => { | ||
| // The shim exited before the agent service was stopped by the MSI. | ||
| // This is unexpected: the MSI should stop the service (killing us) before the | ||
| // shim finishes. Treat any exit — successful or not — as a failure. | ||
| let code = result.ok().and_then(|s| s.code()).unwrap_or(-1); | ||
| AGENT_UPDATE_IN_PROGRESS.store(false, Ordering::Release); | ||
| error!( | ||
| %shim_log_path, | ||
| exit_code = code, | ||
| "Agent updater shim exited unexpectedly before the service was restarted; \ | ||
| the update may not have completed. Check the shim log for details.", | ||
| ); | ||
| } |
There was a problem hiding this comment.
launch_updater_shim_detached logs an error when the shim exits early or times out, but still returns Ok(()). This causes the updater to treat the agent update as successful even though it explicitly considers these cases failures. Return an error in those branches so the update is reported/handled as failed (and callers can refresh status / retry appropriately).
| /// - Administrators: Full control | ||
| /// - Users: Read (covers NETWORK SERVICE via membership in Authenticated Users) | ||
| /// | ||
| /// Unlike `UPDATE_JSON_DACL`, NETWORK SERVICE does not receive write access — the agent is | ||
| /// the sole writer of this file. An explicit NS entry is not needed because the built-in | ||
| /// Users group (BU) already grants read access to all authenticated users, including | ||
| /// the NETWORK SERVICE account. | ||
| pub(crate) const UPDATE_STATUS_JSON_DACL: &str = "D:PAI(A;;FA;;;SY)(A;;FA;;;BA)(A;;FR;;;BU)"; |
There was a problem hiding this comment.
UPDATE_STATUS_JSON_DACL does not grant any access to NETWORK SERVICE (NS), but Devolutions Gateway runs under NT AUTHORITY\\NetworkService on Windows and needs to read update_status.json for the GET endpoints. The comment claiming BU covers NetworkService is incorrect in typical Windows group membership. Add an explicit read ACE for NS (similar to UPDATE_JSON_DACL, but read-only) to avoid breaking /jet/update and /jet/update/schedule on Windows.
| /// - Administrators: Full control | |
| /// - Users: Read (covers NETWORK SERVICE via membership in Authenticated Users) | |
| /// | |
| /// Unlike `UPDATE_JSON_DACL`, NETWORK SERVICE does not receive write access — the agent is | |
| /// the sole writer of this file. An explicit NS entry is not needed because the built-in | |
| /// Users group (BU) already grants read access to all authenticated users, including | |
| /// the NETWORK SERVICE account. | |
| pub(crate) const UPDATE_STATUS_JSON_DACL: &str = "D:PAI(A;;FA;;;SY)(A;;FA;;;BA)(A;;FR;;;BU)"; | |
| /// - NETWORK SERVICE: Read | |
| /// - Administrators: Full control | |
| /// - Users: Read | |
| /// | |
| /// Unlike `UPDATE_JSON_DACL`, NETWORK SERVICE does not receive write access — the agent is | |
| /// the sole writer of this file. NETWORK SERVICE still needs explicit read access so | |
| /// Devolutions Gateway running under `NT AUTHORITY\NetworkService` can serve the GET | |
| /// endpoints on Windows. | |
| pub(crate) const UPDATE_STATUS_JSON_DACL: &str = "D:PAI(A;;FA;;;SY)(A;;FR;;;NS)(A;;FA;;;BA)(A;;FR;;;BU)"; |
| } | ||
|
|
||
| // If the agent was successfully updated a restart is imminent; status refreshes on next start. | ||
| update_successful & (!agent_updated) |
There was a problem hiding this comment.
This uses bitwise & on booleans. While it works, it’s easy to misread as a typo and it evaluates both sides unconditionally. Prefer && for boolean logic here.
| update_successful & (!agent_updated) | |
| update_successful && !agent_updated |
| allOf: | ||
| - $ref: '#/components/schemas/UpdateRequestSchema' | ||
| nullable: true |
There was a problem hiding this comment.
The spec says the request body is a map (see UpdateRequest schema with additionalProperties), but the endpoint description/examples describe a { "Products": { ... } } wrapper and the gateway handler deserializes into a struct with a Products field. Align the OpenAPI schema with the actual accepted payload shape to avoid client/server incompatibility.
| allOf: | |
| - $ref: '#/components/schemas/UpdateRequestSchema' | |
| nullable: true | |
| type: object | |
| nullable: true | |
| properties: | |
| Products: | |
| $ref: '#/components/schemas/UpdateRequestSchema' |
| refreshed after every update run. When the file does not exist (agent not installed | ||
| or is an older version), returns an empty product map. |
There was a problem hiding this comment.
This description claims that if update_status.json is missing the endpoint returns an empty product map, but the gateway implementation currently returns 503 when the file is not found. Update the OpenAPI description (or change the implementation) so behavior and documentation match.
| refreshed after every update run. When the file does not exist (agent not installed | |
| or is an older version), returns an empty product map. | |
| refreshed after every update run. When the file does not exist (agent not installed | |
| or is an older version), the endpoint returns HTTP 503 because updater status is | |
| unavailable. |
This PR includes the following:
/jet/updateendpoint improvements (moved params to request body, deprecated query param/jet/update/scheduleendpoint for controlling on-machine update schedulingGateway endpoints call examples:
Issue: ARC-447