Skip to content

WIP: Agent auto update#1726

Draft
Vladyslav Nikonov (vnikonov-devolutions) wants to merge 3 commits intomasterfrom
feat/agent-auto-update
Draft

WIP: Agent auto update#1726
Vladyslav Nikonov (vnikonov-devolutions) wants to merge 3 commits intomasterfrom
feat/agent-auto-update

Conversation

@vnikonov-devolutions
Copy link
Copy Markdown
Contributor

No description provided.

@github-actions
Copy link
Copy Markdown

Let maintainers know that an action is required on their side

  • Add the label release-required Please cut a new release (Devolutions Gateway, Devolutions Agent, Jetsocat, PowerShell module) when you request a maintainer to cut a new release (Devolutions Gateway, Devolutions Agent, Jetsocat, PowerShell module)

  • Add the label release-blocker Follow-up is required before cutting a new release if a follow-up is required before cutting a new release

  • Add the label publish-required Please publish libraries (`Devolutions.Gateway.Utils`, OpenAPI clients, etc) when you request a maintainer to publish libraries (Devolutions.Gateway.Utils, OpenAPI clients, etc.)

  • Add the label publish-blocker Follow-up is required before publishing libraries if a follow-up is required before publishing libraries

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 Agent as 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.

Comment on lines +201 to +207
let updater_file_path = get_updater_file_path();

if !updater_file_path.exists() {
return Err(
HttpErrorBuilder::new(StatusCode::SERVICE_UNAVAILABLE).msg("Agent updater service is not installed")
);
}
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_updater_file_path() points to .../update.json. Using !exists() as a proxy for “agent updater service is not installed” can produce false negatives (e.g., first run where the file hasn't been created yet, or if the file was deleted) and the error message becomes misleading. Consider attempting to create/write the file and mapping IO errors to 503/500, or checking a more reliable installation marker (service presence / shim executable) if you need an explicit “not installed” signal.

Copilot uses AI. Check for mistakes.

# The actual DevolutionsDesktopAgent.exe will be `\dotnet\DesktopAgent\bin\Release\net48\DevolutionsDesktopAgent.exe`.
# After install, the contsnts of `net48` will be copied to `C:\Program Files\Devolutions\Agent\desktop\`.
# After install, the contsnts of `net48` will be copied to `C:\Program Files\Devolutions\Agent\desktop\`.
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typo in comment: contsntscontents.

Suggested change
# After install, the contsnts of `net48` will be copied to `C:\Program Files\Devolutions\Agent\desktop\`.
# After install, the contents of `net48` will be copied to `C:\Program Files\Devolutions\Agent\desktop\`.

Copilot uses AI. Check for mistakes.
try
{
using StreamReader reader = new StreamReader(path);
config = JsonConvert.DeserializeObject<Dictionary<string, object>>(reader.ReadToEnd());
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JsonConvert.DeserializeObject<Dictionary<string, object>> can legally return null without throwing (e.g., if the file contains JSON null). In that case config becomes null and the subsequent TryGetValue/indexing will throw, failing the install action. Consider null-coalescing the deserialization result to an empty dictionary (or explicitly handling a null root) before using it.

Suggested change
config = JsonConvert.DeserializeObject<Dictionary<string, object>>(reader.ReadToEnd());
config = JsonConvert.DeserializeObject<Dictionary<string, object>>(reader.ReadToEnd()) ?? [];

Copilot uses AI. Check for mistakes.
updaterSection = existingObj.ToObject<Dictionary<string, object>>();
}

updaterSection["AgentAutoUpdate"] = new Dictionary<string, bool> { { "Enabled", enable } };
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This overwrites the entire Updater section (and AgentAutoUpdate sub-object) with a new dictionary containing only Enabled. That will drop any existing updater settings (e.g., Updater.Enabled, interval/window values), which is likely unintended and can surprise admins who configured those fields. Prefer preserving existing Updater/AgentAutoUpdate objects and only updating the Enabled key.

Suggested change
updaterSection["AgentAutoUpdate"] = new Dictionary<string, bool> { { "Enabled", enable } };
// Navigate into or create the "AgentAutoUpdate" section and only update the Enabled key.
Dictionary<string, object> autoUpdateSection;
if (updaterSection.TryGetValue("AgentAutoUpdate", out object existingAutoUpdate))
{
if (existingAutoUpdate is Newtonsoft.Json.Linq.JObject existingAutoUpdateObj)
{
autoUpdateSection = existingAutoUpdateObj.ToObject<Dictionary<string, object>>() ?? [];
}
else if (existingAutoUpdate is Dictionary<string, object> existingAutoUpdateDict)
{
autoUpdateSection = existingAutoUpdateDict;
}
else
{
autoUpdateSection = [];
}
}
else
{
autoUpdateSection = [];
}
autoUpdateSection["Enabled"] = enable;
updaterSection["AgentAutoUpdate"] = autoUpdateSection;

Copilot uses AI. Check for mistakes.
Comment on lines +409 to +416
// 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,
};
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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”.

Copilot uses AI. Check for mistakes.
/// is available, trigger a silent MSI update during the configured maintenance window.
///
/// This setting can be managed remotely via the Devolutions Gateway API
/// (`GET`/`POST /jet/update-agent`) or set directly in this file.
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation references GET/POST /jet/update-agent, but the router in this PR exposes /jet/agent-update and /jet/agent-update-config. Please update the comment so operators can find the correct endpoints.

Suggested change
/// (`GET`/`POST /jet/update-agent`) or set directly in this file.
/// (`GET`/`POST /jet/agent-update` and `/jet/agent-update-config`) or set directly in this file.

Copilot uses AI. Check for mistakes.
///
/// Returns the current `AgentAutoUpdate` configuration from `agent.json`.
/// When the section is absent the response contains the built-in defaults
/// (`Enabled: false`, `IntervalHours: 24`, window `02:00`-`04:00`).
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This docstring mentions defaults like IntervalHours: 24, but the API types/config use Interval as a humantime string (default "1d") and window fields UpdateWindowStart/End. Updating the comment to match the actual payload would avoid confusing API consumers.

Suggested change
/// (`Enabled: false`, `IntervalHours: 24`, window `02:00`-`04:00`).
/// (`Enabled: false`, `Interval: "1d"`, `UpdateWindowStart: "02:00"`, `UpdateWindowEnd: "04:00"`).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member

@CBenoit Benoît Cortier (CBenoit) left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 💡

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/update instead? (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/agent to signal the relationship, rather than an orthogonal path?
  • Who should be authorized to trigger an Agent update? Same gateway.update scope, 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/update uses 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)?

Comment on lines +209 to +225
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(),
)?;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: Here we handle the json serialization and filesystem write in Devolutions Gateway itself.

Copy link
Copy Markdown
Member

@CBenoit Benoît Cortier (CBenoit) Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.


[target.'cfg(windows)'.dependencies]
aws-lc-rs = "1.15"
humantime = "2"
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: I would rather avoid humantime since this is purely machine-to-machine communication. We don’t need to add parsing overhead I think.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +11 to +18
[[bin]]
name = "devolutions-agent"
path = "src/main.rs"

[[bin]]
name = "devolutions-agent-updater"
path = "src/agent-updater.rs"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@CBenoit
Copy link
Copy Markdown
Member

Also, please link a Jira ticket! Thanks 🙂

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

4 participants