Skip to content

Commit 1f139e5

Browse files
Turn the implement-ticket agent into a spec, implement, check and review pipeline (#5575)
* Make the implement-ticket agent prove its change and build faster The first agent run only screenshotted that a new control existed, never used it, captured a page still loading, lost a turn to a blocked Stop-Process pipeline, and spent most of its time waiting on builds and formatters. - Add Stop-UniGetUIDev.ps1, which stops UniGetUI by process id, since Copilot CLI refuses `Get-Process | Stop-Process`. - Add Wait-UniGetUIIdle.ps1, which waits until the page has finished loading packages (LoadingProgressBar is out of the UI Automation tree) and the window has stopped changing, and can save a screenshot then. - Keep the shared compiler server for the agent's dev builds and skip restore while project files are unchanged; locally, a rebuild after an edit drops from 33-39s to 15s. - Have the prompt ask for acceptance criteria and assumptions up front, a screenshot of each criterion being exercised through the UI, one formatter pass at the end, and fix its claim that no packages are installed on the runner. - Warn from the export step when code changed without any after-*.png. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Install AwakeCoding.PSRemoting with ModuleFast in the desktop session IronRDP's harness installs the module with Install-Module when it is missing. Installing it first with ModuleFast, pinned to v0.6.1 and to AwakeCoding.PSRemoting 2026.10.21, into the same CurrentUser folder lets the harness skip that install; locally ModuleFast takes about 5s where Save-Module takes 12s. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Install Devolutions Agent with its RDP extension in the desktop session The agent's RDP extension (MSI feature F.Session, Session.Enabled in agent.json) starts DevolutionsSession in each RDP session, which serves the NOW protocol over a virtual channel for remote execution. - Install the Devolutions.Agent winget package, 2026.3.1.0 by default, with ADDLOCAL=F.Agent,F.Session, before the desktop session connects: the agent only starts DevolutionsSession on RDP connect and logon. winget is not on the runner image, so the MSI comes from the package's winget manifest and is checked against the manifest's SHA256. - Check that DevolutionsSession runs in the desktop session, and copy the agent's config and logs into the session artifacts. - An empty devolutions-agent-version input skips both steps. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Negotiate the NOW channel as soon as the desktop session connects The first run with Devolutions Agent showed DevolutionsSession starting in the RDP session and exiting 5 seconds later. It opens the Devolutions::Now::Agent channel and gives up unless NOW negotiation starts within 5 seconds, while ironrdp-agent only negotiates on its first NOW request, so nothing negotiated and the agent never restarted it. - Start-DesktopSession.ps1 -NowChannel runs `ironrdp-agent now capabilities` right after connecting; it waits for the channel and negotiates in time, and the daemon keeps the channel alive afterwards. - The check step now runs a PowerShell command over NOW and checks that it ran in the desktop session, and also collects DevolutionsSession's logs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Make the NOW channel handshake opt-in until ironrdp-agent can send on it The second run negotiated in time, but the RDP session dropped on the first NOW message: ironrdp-dvc-pipe-proxy encodes its messages for the channel yet hands them to the client as channel 0, and the client's DvcMessageBatch::try_new rejects the mismatch and ends the session. Devolutions Agent is still installed; the handshake and the remote execution check only run with the new now-channel input set to true. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Give the implement-ticket agent Avalonia's Build MCP The agent now gets the avalonia-docs MCP server (https://docs-mcp.avaloniaui.net/mcp) through --additional-mcp-config, and the prompt has it load get_avalonia_expert_rules before writing UI code and look up Avalonia 12 APIs in the docs rather than from memory. It is a public, read-only documentation service and gets no credential. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Restore again after a Release restore, and keep the agent off dotnet publish In run 39806855126 the agent ran a NativeAOT publish twice (about 6 minutes). Its Release restore rewrote project.assets.json without the Debug-only diagnostics package, and Start-UniGetUIDev.ps1 then skipped restore because no project file had changed, so the next Debug build failed. - The restore stamp also records each project's project.assets.json timestamp, so any other restore forces a fresh one. - The prompt tells the agent not to run dotnet publish; the PR's CI checks the NativeAOT build. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Drop the Rust build cache from the desktop-session action Swatinem/rust-cache saved about 400 MB per branch (77s at job end), and with the repository's Actions cache at its 10 GB limit that evicted the cached ironrdp-agent and NuGet packages, so run 39862220431 rebuilt ironrdp-agent and restored NuGet from scratch. The built agent is already cached by IronRDP commit, so the Rust cache only shortened the rare full rebuild (about 9 minutes without it). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Add spec approval, checks, review, evidence, triggers and eval to the agent workflow Turns the implement-ticket workflow into a pipeline around the agent: - Spec stage: mode spec (or the agent:spec label, or "/agent spec" on an issue) has an agent write a spec with acceptance criteria and a UI sketch, posted on the issue; "/agent implement" (or spec_run_id) implements the approved spec. - Checks after the agent (Test-AgentChange.ps1): paths, build, the tests of projects that reference the change, formatting of the changed files, and new translation keys, reported in the PR. - Review by a second agent on a different model (REVIEW_PROMPT.md), with its verdict and findings in the PR. - Screenshot evidence (New-ScreenshotEvidence.ps1): taskbar cropped, each before/after step pair outlined in red, nearly identical pairs flagged. - Triggers: agent:spec / agent:implement labels, "/agent ..." comments on issues, "/agent <feedback>" on agent PRs to iterate on them, all limited to users with write access (Resolve-AgentRequest.ps1); results are posted back on the issue. - Invoke-CopilotAgent.ps1 runs every agent with JSON output and records the model, Copilot usage and what the agent ran; the report adds the phase timings. agent-eval.yml replays merged PRs and scores them. - NuGet packages are restored from main's cache without saving one per agent branch; Invoke-DesktopNowCommand runs commands over NOW once the desktop session's now-channel input is on. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * TEMPORARY: run the agent eval on pushes to this branch (case 5561) A new workflow can only be dispatched once it is on the default branch. To be removed before merging. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Start the agent when called from the eval, whatever the caller's event A called workflow sees its caller's event, so a push-triggered eval skipped every job. A run with an artifact prefix is a workflow call. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Enable the NOW channel in the agent workflow with the IronRDP pipe proxy fix The desktop-session action now applies ironrdp-patches/*.patch on top of ironrdp-ref before building ironrdp-agent, and keys the agent's cache on them. 0001 is IronRDP 2cc70912, not merged upstream yet: the DVC pipe proxy reported channel 0 for the NOW messages it sent, and the client dropped the RDP session. With it, the agent workflow turns now-channel on, so DevolutionsSession's NOW channel is negotiated and checked, and the agent can use Invoke-DesktopNowCommand. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Keep the IronRDP patches' line endings on Windows checkouts Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Name the PR description path in the agent's prompt, and keep the report In eval run 39893339409 the agent wrote its description next to the ticket instead of to $env:AGENT_PR_BODY, so the report and the review had none. The prompt now names that file and the screenshots folder. The report is also uploaded and printed, and the review tries Sonnet before Opus (34 AI credits for one review against 4.6 for the change). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Remove the temporary push trigger of the agent eval Eval run 39893339409 (case 5561) passed through it; from the default branch the eval is started by hand. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Run the TUI tests in the agent checks only when the TUI changed In run 39899544626 UniGetUI.Tui.Tests crashed in its test host after 104 of 225 tests (Avalonia CreateWindow), where run 39893563369 had passed them. They take about five minutes; the pull request's CI runs them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Fix review findings and failed checks once, on the same desktop The review moves into the desktop job, after the checks and the screenshot evidence, so its findings can be fixed where the app runs: - REVIEW_PROMPT.md asks for structured findings (id, severity, file, line, details, fix) with an implementable flag: local, one clear resolution, no design or spec decision. - Invoke-AgentReview.ps1 runs the reviewer on a different model against the working tree and puts the implementer's change back if the review touched it. - Select-AgentFixes.ps1 picks, without an agent, the failed checks and the implementable blocker, major and minor findings. - One fix round (FIX_PROMPT.md) fixes only those, on the same desktop, updates the description and reports each item fixed or not. The checks and the evidence then run again; the first round is kept in before-fix/. There is no second review or second fix round. - The report lists each problem with its outcome, what was left for the maintainer, the checks before the fix, and the fixer's metrics; the eval counts cases with no blocker or major finding left unfixed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Retry the NOW check, and go on without NOW when it keeps failing In run 39958994732 the NOW handshake succeeded but the check's command failed with "NOW operation stream closed before a terminal event", and DevolutionsSession logged nothing after the handshake; the same check passed in run 39893563369. The command is now tried three times; if it still fails, the job continues with DESKTOP_SESSION_NOW=0 and a warning. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Pin IronRDP to the merge of its DVC pipe proxy fix, and drop the patch IronRDP#2209 (10199db3) merged the fix that ironrdp-patches/0001 carried: the pipe proxy now sends NOW messages on their own channel. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Skip the agent report after a cancelled run, and report a failed one The report job ran after cancelled run 39969885850 and failed to download the change it never got. It now runs only after an implement job that succeeded or failed, tolerates a missing change, and the report script no longer trips over an empty fix request in strict mode. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Put the agent job's NuGet caches on D:, and stop caching them between runs Following https://awakecoding.com/posts/speeding-up-dotnet-builds-on-windows-nuget-cache/: on Windows runners NuGet's package and HTTP caches default to C:, the slow OS disk. NUGET_PACKAGES and NUGET_HTTP_CACHE_PATH now point under RUNNER_TEMP on D: for the whole job (base build, the agents' builds and tests, the checks). The NuGet cache restored from main is dropped: every run restores from scratch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Put NuGet's HTTP cache next to the packages in the other .NET workflows build-release, dotnet-test, cli-headless-e2e and the unigetui-build action already keep NUGET_PACKAGES in the workspace, on D: on Windows runners. NUGET_HTTP_CACHE_PATH now goes beside it in .nuget/http-cache instead of defaulting to C:. Their NuGet caches are unchanged and still cover only .nuget/packages. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Set the NuGet cache folders once per workflow, not per job or action NUGET_PACKAGES and NUGET_HTTP_CACHE_PATH move to the workflow-level env of build-release, dotnet-test and cli-headless-e2e, and desktop-smoke gets them too; the unigetui-build action no longer sets its own and uses its caller's. dotnet-test keeps its exact path, so its NuGet cache still matches; build-release's Windows build job now uses the forward slashes of its other jobs, so its cache misses once. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 8765b34 commit 1f139e5

31 files changed

Lines changed: 2772 additions & 133 deletions

‎.gitattributes‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,6 @@ src/UniGetUI.PackageEngine.Managers.Chocolatey/choco-cli/** linguist-vendored
22
UniGetUI.iss whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol
33
.githooks/* text eol=lf
44

5-
.github/workflows/*.lock.yml linguist-generated=true
5+
.github/workflows/*.lock.yml linguist-generated=true
6+
# Patches applied to other repositories must keep their line endings.
7+
.github/actions/desktop-session/ironrdp-patches/*.patch -text

‎.github/actions/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,4 +102,4 @@ remoting port and the desktop's Windows session id), `DESKTOP_SESSION_SIZE` and
102102
- UniGetUI's dialogs are drawn inside its main window, not as separate windows. UI Automation
103103
exposes the open dialog's title as the element with AutomationId `ModalTitle`. Escape closes it.
104104
- IronRDP is pinned through the `ironrdp-ref` input of `desktop-session`. Changing it rebuilds
105-
and recaches `ironrdp-agent`, which takes about 5 minutes. A cache hit restores it in seconds.
105+
and recaches `ironrdp-agent`, which takes about 9 minutes. A cache hit restores it in seconds.

‎.github/actions/desktop-session/DesktopSession.psm1‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,25 @@ function Invoke-DesktopCommand {
239239
Invoke-Command -Session (Get-DesktopPSSession) -ScriptBlock $ScriptBlock -ArgumentList $ArgumentList
240240
}
241241

242+
# Runs a command in the desktop session over the NOW protocol: Devolutions Agent's RDP
243+
# extension runs it as the session's user, and its output comes back over the RDP virtual
244+
# channel. Needs the desktop-session action's now-channel input (DESKTOP_SESSION_NOW=1).
245+
# Throws when the command fails; returns its output.
246+
function Invoke-DesktopNowCommand {
247+
param(
248+
[Parameter(Mandatory)]
249+
[string] $Command,
250+
251+
[ValidateSet('powershell', 'pwsh')]
252+
[string] $Shell = 'powershell'
253+
)
254+
255+
if ($env:DESKTOP_SESSION_NOW -ne '1') {
256+
throw 'The NOW channel is not available in this desktop session (desktop-session now-channel input is off)'
257+
}
258+
Invoke-DesktopAgent now $Shell $Command
259+
}
260+
242261
# Starts a program on the desktop and returns its process id and Windows session id.
243262
function Start-DesktopProcess {
244263
param(
@@ -419,6 +438,7 @@ Export-ModuleMember -Function @(
419438
'Get-DesktopPSSession',
420439
'Close-DesktopPSSession',
421440
'Invoke-DesktopCommand',
441+
'Invoke-DesktopNowCommand',
422442
'Start-DesktopProcess',
423443
'Get-DesktopWindow',
424444
'Show-DesktopWindow'

‎.github/actions/desktop-session/Start-DesktopSession.ps1‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@ param(
1616
[string] $DesktopSize = '1920x1080',
1717

1818
[Parameter(Mandatory)]
19-
[string] $ArtifactsDir
19+
[string] $ArtifactsDir,
20+
21+
# Negotiate the NOW channel with Devolutions Agent's DevolutionsSession right after connecting.
22+
[switch] $NowChannel
2023
)
2124

2225
Set-StrictMode -Version Latest
@@ -160,6 +163,27 @@ try {
160163
$connected = $true
161164
Write-Host '::endgroup::'
162165

166+
if ($NowChannel) {
167+
# DevolutionsSession, which Devolutions Agent starts once the session logs on, opens the
168+
# Devolutions::Now::Agent channel and exits unless NOW negotiation starts within 5
169+
# seconds. The daemon only negotiates on its first NOW request, which waits up to 30
170+
# seconds for the channel, so that request is made right away. The daemon then keeps
171+
# the negotiated channel alive.
172+
Write-Host '::group::Negotiate NOW channel'
173+
$capabilities = & $AgentPath --endpoint $endpoint now capabilities 2>&1 | Out-String
174+
$capabilities | Set-Content -Path (Join-Path $ArtifactsDir 'now-capabilities.txt') -Encoding utf8NoBOM
175+
Write-Host $capabilities.Trim()
176+
if ($LASTEXITCODE -ne 0) {
177+
throw "NOW negotiation with DevolutionsSession failed (exit code $LASTEXITCODE)"
178+
}
179+
# Tells later steps (and Invoke-DesktopNowCommand) that the NOW channel is up.
180+
$env:DESKTOP_SESSION_NOW = '1'
181+
if (-not [string]::IsNullOrWhiteSpace($env:GITHUB_ENV)) {
182+
'DESKTOP_SESSION_NOW=1' | Add-Content -Path $env:GITHUB_ENV -Encoding utf8
183+
}
184+
Write-Host '::endgroup::'
185+
}
186+
163187
Write-Host '::group::Start interactive PSHostServer'
164188
Start-ScheduledTask -TaskName $taskName
165189
& (Join-Path $harnessRoot 'Start-InteractivePSHostServer.ps1') `

‎.github/actions/desktop-session/action.yml‎

Lines changed: 153 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,27 @@ description: >
88
inputs:
99
ironrdp-ref:
1010
description: IronRDP commit providing ironrdp-agent and the testing/agentic-rdp harness
11-
# IronRDP master at the merge of https://github.com/Devolutions/IronRDP/pull/2189, which
12-
# hardened the harness. Pinned so the built agent can be cached by commit; changing it
13-
# rebuilds ironrdp-agent once (about 5 minutes).
14-
default: 75ee516f34562516bd400dbf6e2ef93de0521f22
11+
# IronRDP master at the merge of https://github.com/Devolutions/IronRDP/pull/2209, which
12+
# fixed the DVC pipe proxy for the NOW channel. Pinned so the built agent can be cached by
13+
# commit; changing it rebuilds ironrdp-agent once (about 9 minutes).
14+
default: 10199db3b0aeca629adc02234ffdbb570bb77d43
1515
desktop-size:
1616
description: RDP desktop size, as WxH
1717
default: 1920x1080
1818
artifacts-dir:
1919
description: Directory for screenshots, logs and session state; defaults to RUNNER_TEMP/desktop-session
2020
default: ''
21+
devolutions-agent-version:
22+
description: >
23+
Version of the Devolutions.Agent winget package to install with its RDP extension, which
24+
serves the NOW protocol over an RDP virtual channel in the desktop session; empty skips it
25+
default: 2026.3.1.0
26+
now-channel:
27+
description: >
28+
Negotiate the NOW channel with Devolutions Agent right after connecting, and check that a
29+
command runs over it (Invoke-DesktopNowCommand). Needs an ironrdp-ref that includes
30+
IronRDP#2209; before it, the first NOW message dropped the RDP session
31+
default: 'false'
2132

2233
outputs:
2334
agent-path:
@@ -47,7 +58,10 @@ runs:
4758
steps:
4859
# Cloned outside the workspace (actions/checkout can only write inside it), so the
4960
# caller's checkout stays clean for agents, git and tests that scan the repository.
61+
# ironrdp-patches can hold fixes not yet merged in IronRDP, applied on top of ironrdp-ref
62+
# (none at the moment).
5063
- name: Fetch IronRDP
64+
id: fetch
5165
shell: pwsh
5266
env:
5367
IRONRDP_REF: ${{ inputs.ironrdp-ref }}
@@ -60,20 +74,29 @@ runs:
6074
git -C $env:IRONRDP_ROOT checkout --quiet --detach FETCH_HEAD
6175
if ($LASTEXITCODE -ne 0) { throw "git checkout failed with exit code $LASTEXITCODE" }
6276
63-
# The agent only changes with ironrdp-ref, so a cache hit skips the Rust build entirely.
77+
$patches = @(Get-ChildItem -Path (Join-Path $env:GITHUB_ACTION_PATH 'ironrdp-patches') -Filter '*.patch' -File -ErrorAction SilentlyContinue | Sort-Object Name)
78+
foreach ($patch in $patches) {
79+
git -C $env:IRONRDP_ROOT apply $patch.FullName
80+
if ($LASTEXITCODE -ne 0) { throw "Could not apply $($patch.Name) to IronRDP $env:IRONRDP_REF" }
81+
Write-Host "Applied $($patch.Name)"
82+
}
83+
$patchHash = if ($patches.Count -gt 0) {
84+
(($patches | Get-FileHash -Algorithm SHA256 | ForEach-Object Hash) -join '').Substring(0, 12)
85+
} else { 'none' }
86+
"patches=$patchHash" | Add-Content -Path $env:GITHUB_OUTPUT -Encoding utf8
87+
88+
# The agent only changes with ironrdp-ref and the patches, so a cache hit skips the Rust
89+
# build entirely.
6490
- name: Restore ironrdp-agent
6591
id: agent-cache
6692
uses: actions/cache/restore@v6
6793
with:
6894
path: ${{ runner.temp }}/ironrdp/target/release/ironrdp-agent.exe
69-
key: ironrdp-agent-${{ runner.os }}-${{ inputs.ironrdp-ref }}
70-
71-
- name: Rust cache
72-
if: steps.agent-cache.outputs.cache-hit != 'true'
73-
uses: Swatinem/rust-cache@v2.9.1
74-
with:
75-
workspaces: ${{ runner.temp }}/ironrdp
95+
key: ironrdp-agent-${{ runner.os }}-${{ inputs.ironrdp-ref }}-${{ steps.fetch.outputs.patches }}
7696

97+
# No Rust build cache: it saved about 400 MB per branch, and the repository's 10 GB Actions
98+
# cache then evicted this agent and the NuGet packages. A build from scratch is rare
99+
# (a new ironrdp-ref, or an evicted agent) and takes about 9 minutes.
77100
- name: Build ironrdp-agent
78101
if: steps.agent-cache.outputs.cache-hit != 'true'
79102
shell: pwsh
@@ -97,13 +120,85 @@ runs:
97120
path: ${{ runner.temp }}/ironrdp/target/release/ironrdp-agent.exe
98121
key: ${{ steps.agent-cache.outputs.cache-primary-key }}
99122

123+
# The harness drives the interactive desktop through AwakeCoding.PSRemoting, and installs
124+
# it with Install-Module when it is missing. ModuleFast installs it in about half the time,
125+
# into the same CurrentUser folder, so the harness finds it and skips its own install.
126+
- name: Install PowerShell modules
127+
shell: pwsh
128+
env:
129+
MODULEFAST_RELEASE: v0.6.1
130+
MODULES: AwakeCoding.PSRemoting=2026.10.21
131+
run: |
132+
# Created up front: ModuleFast asks before creating a destination folder.
133+
$destination = Join-Path ([Environment]::GetFolderPath('MyDocuments')) 'PowerShell\Modules'
134+
New-Item -Path $destination -ItemType Directory -Force | Out-Null
135+
$bootstrap = Invoke-RestMethod "https://github.com/JustinGrote/ModuleFast/releases/download/$env:MODULEFAST_RELEASE/ModuleFast.ps1"
136+
& ([scriptblock]::Create($bootstrap)) -Release $env:MODULEFAST_RELEASE `
137+
-Specification ($env:MODULES -split ',') `
138+
-Destination $destination `
139+
-NoPSModulePathUpdate `
140+
-NoProfileUpdate
141+
142+
# Devolutions Agent with its RDP extension (the F.Session feature, which sets Session.Enabled
143+
# in agent.json): the agent starts DevolutionsSession in each RDP session, and it serves the
144+
# NOW protocol over a virtual channel for remote execution. The agent only does that when an
145+
# RDP session connects, so it is installed before the desktop session is. winget is not on
146+
# the runner image, so the MSI comes from the package's winget manifest, checked against the
147+
# manifest's hash.
148+
- name: Install Devolutions Agent
149+
if: inputs.devolutions-agent-version != ''
150+
shell: pwsh
151+
env:
152+
AGENT_VERSION: ${{ inputs.devolutions-agent-version }}
153+
ARTIFACTS_DIR: ${{ inputs.artifacts-dir }}
154+
run: |
155+
$artifactsDir = $env:ARTIFACTS_DIR
156+
if ([string]::IsNullOrWhiteSpace($artifactsDir)) {
157+
$artifactsDir = Join-Path $env:RUNNER_TEMP 'desktop-session'
158+
}
159+
New-Item -Path $artifactsDir -ItemType Directory -Force | Out-Null
160+
161+
$manifestUrl = "https://raw.githubusercontent.com/microsoft/winget-pkgs/master/manifests/d/Devolutions/Agent/$env:AGENT_VERSION/Devolutions.Agent.installer.yaml"
162+
$manifest = Invoke-RestMethod -Uri $manifestUrl
163+
$installer = [regex]::Match($manifest, '(?m)^- Architecture: x64\s*\r?\n\s+InstallerUrl: (?<url>\S+)\s*\r?\n\s+InstallerSha256: (?<sha>[0-9A-Fa-f]{64})')
164+
if (-not $installer.Success) {
165+
throw "No x64 installer in $manifestUrl"
166+
}
167+
168+
$msiPath = Join-Path $env:RUNNER_TEMP 'DevolutionsAgent.msi'
169+
Invoke-WebRequest -Uri $installer.Groups['url'].Value -OutFile $msiPath
170+
$hash = (Get-FileHash -Path $msiPath -Algorithm SHA256).Hash
171+
if ($hash -ne $installer.Groups['sha'].Value.ToUpperInvariant()) {
172+
throw "$($installer.Groups['url'].Value) has SHA256 $hash, but the winget manifest expects $($installer.Groups['sha'].Value)"
173+
}
174+
175+
$logPath = Join-Path $artifactsDir 'devolutions-agent-install.log'
176+
$msiexec = Start-Process -FilePath 'msiexec.exe' -Wait -PassThru -ArgumentList @(
177+
'/i', "`"$msiPath`"", '/qn', '/norestart', 'ADDLOCAL=F.Agent,F.Session', '/l*v', "`"$logPath`""
178+
)
179+
if ($msiexec.ExitCode -notin 0, 3010) {
180+
throw "Devolutions Agent setup failed with exit code $($msiexec.ExitCode); log in $logPath"
181+
}
182+
183+
$configPath = Join-Path $env:ProgramData 'Devolutions\Agent\agent.json'
184+
$config = Get-Content -Path $configPath -Raw | ConvertFrom-Json
185+
if (-not ($config.PSObject.Properties['Session'] -and $config.Session.Enabled)) {
186+
throw "Session.Enabled is not set in $configPath after installing the RDP extension"
187+
}
188+
$service = Get-Service -Name 'DevolutionsAgent'
189+
if ($service.Status -ne 'Running') {
190+
Start-Service -Name 'DevolutionsAgent'
191+
}
192+
Write-Host "Devolutions Agent $env:AGENT_VERSION installed with its RDP extension; service $((Get-Service -Name 'DevolutionsAgent').Status)"
193+
100194
- name: Start desktop session
101195
id: start
102196
shell: pwsh
103197
env:
104198
IRONRDP_ROOT: ${{ runner.temp }}/ironrdp
105199
DESKTOP_SIZE: ${{ inputs.desktop-size }}
106200
ARTIFACTS_DIR: ${{ inputs.artifacts-dir }}
201+
NOW_CHANNEL: ${{ inputs.devolutions-agent-version != '' && inputs.now-channel == 'true' }}
107202
run: |
108203
$artifactsDir = $env:ARTIFACTS_DIR
109204
if ([string]::IsNullOrWhiteSpace($artifactsDir)) {
@@ -113,4 +208,49 @@ runs:
113208
& "$env:GITHUB_ACTION_PATH\Start-DesktopSession.ps1" `
114209
-IronRdpRoot $env:IRONRDP_ROOT `
115210
-DesktopSize $env:DESKTOP_SIZE `
116-
-ArtifactsDir $artifactsDir
211+
-ArtifactsDir $artifactsDir `
212+
-NowChannel:($env:NOW_CHANNEL -eq 'true')
213+
214+
# Checks the RDP extension end to end: DevolutionsSession runs in the desktop session, and a
215+
# command sent over the NOW channel runs there. The command is retried, since it sometimes
216+
# fails with "NOW operation stream closed before a terminal event" right after the
217+
# handshake. If NOW still doesn't work, later steps go without it (DESKTOP_SESSION_NOW=0)
218+
# rather than the whole job failing.
219+
- name: Check NOW remote execution
220+
if: inputs.devolutions-agent-version != '' && inputs.now-channel == 'true'
221+
shell: pwsh
222+
env:
223+
SESSION_ID: ${{ steps.start.outputs.session-id }}
224+
run: |
225+
$sessionId = [int] $env:SESSION_ID
226+
227+
# The agent's and DevolutionsSession's logs go with the desktop session artifacts either way.
228+
$agentLogs = Join-Path $env:DESKTOP_SESSION_ARTIFACTS 'devolutions-agent'
229+
New-Item -Path $agentLogs -ItemType Directory -Force | Out-Null
230+
Get-ChildItem -Path (Join-Path $env:ProgramData 'Devolutions\Agent') -File -Filter 'agent*' |
231+
Copy-Item -Destination $agentLogs
232+
Get-ChildItem -Path (Join-Path $env:APPDATA 'Devolutions\Session') -File -ErrorAction SilentlyContinue |
233+
Copy-Item -Destination $agentLogs
234+
235+
$process = Get-Process -Name 'DevolutionsSession' -ErrorAction SilentlyContinue |
236+
Where-Object { $_.SessionId -eq $sessionId } |
237+
Select-Object -First 1
238+
$failure = if ($null -eq $process) { "DevolutionsSession is not running in desktop session $sessionId" } else { $null }
239+
240+
# The remote command prints the session it runs in, which must be the desktop session.
241+
for ($attempt = 1; $null -ne $process -and $attempt -le 3; $attempt++) {
242+
$output = & $env:DESKTOP_SESSION_AGENT --endpoint $env:DESKTOP_SESSION_ENDPOINT now powershell '(Get-Process -Id $PID).SessionId' 2>&1 | Out-String
243+
if ($LASTEXITCODE -eq 0 -and $output -match "(?m)^\s*$sessionId\s*$") {
244+
$failure = $null
245+
Write-Host "DevolutionsSession (pid $($process.Id)) ran a PowerShell command in desktop session $sessionId over NOW (attempt $attempt)"
246+
break
247+
}
248+
$failure = "NOW remote execution in desktop session $sessionId failed (exit code $LASTEXITCODE): $($output.Trim())"
249+
Write-Host "Attempt ${attempt}: $failure"
250+
Start-Sleep -Seconds 5
251+
}
252+
253+
if ($failure) {
254+
Write-Host "::warning::$failure. The desktop session continues without NOW; logs in $agentLogs"
255+
'DESKTOP_SESSION_NOW=0' | Add-Content -Path $env:GITHUB_ENV -Encoding utf8
256+
}

‎.github/actions/unigetui-build/action.yml‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,9 @@ runs:
4040
- name: Build UniGetUI installer
4141
id: build
4242
shell: pwsh
43+
# NuGet's cache folders (NUGET_PACKAGES, NUGET_HTTP_CACHE_PATH) come from the calling
44+
# workflow's env.
4345
env:
44-
NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages
4546
OUTPUT_DIR: ${{ inputs.output-dir }}
4647
# MSBuild reads environment variables as properties. As in build-release.yml, build
4748
# project references one at a time without the shared compiler server: parallel

0 commit comments

Comments
 (0)