From 0fd0e108907184ca3fccd9a28b324d26b26ad608 Mon Sep 17 00:00:00 2001 From: Marc-Andre Moreau Date: Mon, 9 Nov 2026 16:07:25 -0500 Subject: [PATCH 1/3] feat(agent,agent-installer): add macOS installer package Devolutions Agent now ships for macOS as one installer package per architecture. The package installs the `com.devolutions.agent` launchd daemon, which runs as root, starts at boot, and restarts after a crash. Installing creates a default configuration when none exists, and upgrading keeps the existing one. `devolutions-agent-uninstall` removes the Agent, and `--purge` also removes its configuration and logs. On macOS, `devolutions-agent start` and `stop` act on the installed daemon. The macOS identity, tunnel, and PowerShell Universal features match Linux. The Package workflow signs the executable, signs the installer with a Developer ID Installer certificate, and notarizes it. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/README.md | 1 + .github/workflows/ci.yml | 24 +++++- .github/workflows/package.yml | 76 +++++++++++++++-- .github/workflows/release.yml | 9 ++ ci/tlk.ps1 | 85 +++++++++++++++++++ devolutions-agent/src/cli.rs | 10 ++- package/AgentMacOS/README.md | 48 +++++++++++ package/AgentMacOS/Test-Package.sh | 64 ++++++++++++++ .../AgentMacOS/com.devolutions.agent.plist | 30 +++++++ .../AgentMacOS/devolutions-agent-uninstall | 39 +++++++++ package/AgentMacOS/distribution.xml | 20 +++++ package/AgentMacOS/scripts/postinstall | 24 ++++++ package/AgentMacOS/scripts/preinstall | 18 ++++ 13 files changed, 439 insertions(+), 9 deletions(-) create mode 100644 package/AgentMacOS/README.md create mode 100755 package/AgentMacOS/Test-Package.sh create mode 100644 package/AgentMacOS/com.devolutions.agent.plist create mode 100755 package/AgentMacOS/devolutions-agent-uninstall create mode 100644 package/AgentMacOS/distribution.xml create mode 100755 package/AgentMacOS/scripts/postinstall create mode 100755 package/AgentMacOS/scripts/preinstall diff --git a/.github/workflows/README.md b/.github/workflows/README.md index d96af30644..3f61bd176e 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -57,6 +57,7 @@ The workflow will display a notice if `run` was not built from the main branch. - The `Codesign` job uses a matrix to code-sign and repackage in parallel. It's important to ensure that individual matrix jobs do not upload the same artifacts; otherwise the result may be unexpected. For example: each platform downloads the `jetsocat` artifact, which contains builds for several operating systems. The Windows job must only sign and upload the Windows builds. If it were allowed to upload the macOS builds, they will be unsigned and *may* overwrite the signed builds uploaded by the macOS job. - The workflow makes a checkout of the repository at the commit that `run` was built from. This ensures consistency with the [CI](#ci) workflow when repackaging. Remember that tools invoked by the workflow (e.g. [tlk.ps1](../../ci/tlk.ps1)) will be from that commit too. +- The Devolutions Agent macOS installer is signed with the `APPLE_APP_DEV_ID_INSTALLER_CERTIFICATE` secret, a base64 Developer ID Installer PKCS #12, and its `APPLE_APP_DEV_ID_INSTALLER_CERTIFICATE_PASSWORD`, then notarized with `APPLE_BOT_PASSWORD`. Without them, the package stays unsigned, the job warns, and `publish-prod` fails. ##### TODO diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6bf7d723ce..9c112f7f3a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,8 +65,8 @@ jobs: $Jobs = @() - $Platforms | ForEach-Object { - $Runner = switch ($_) { 'windows' { 'windows-2022' } 'linux' { 'ubuntu-22.04' } } + $Platforms + 'macos' | ForEach-Object { + $Runner = switch ($_) { 'windows' { 'windows-2022' } 'macos' { 'macos-15' } 'linux' { 'ubuntu-22.04' } } foreach ($Arch in $Archs) { $Jobs += @{ arch = $Arch @@ -1136,6 +1136,17 @@ jobs: with: ref: ${{ needs.preflight.outputs.ref }} + # See the jetsocat job: the macos-15 image's ~/.cargo/bin shims are broken. + - name: Repair Rust shims (macOS) + if: ${{ matrix.os == 'macos' }} + run: | + set -eux + rm -rf "$HOME/.cargo/bin" + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ + | bash -s -- -y --default-toolchain none --no-modify-path --profile minimal + echo "$HOME/.cargo/bin" >> $GITHUB_PATH + shell: bash + - name: Setup Rust cache uses: ./.github/actions/setup-rust-cache with: @@ -1280,6 +1291,10 @@ jobs: rustup target add aarch64-pc-windows-msvc shell: pwsh + - name: Configure macOS (intel) runner + if: ${{ matrix.os == 'macos' && matrix.arch == 'x64' }} + run: rustup target add x86_64-apple-darwin + - name: Add msbuild to PATH if: ${{ matrix.os == 'windows' }} uses: microsoft/setup-msbuild@v3 @@ -1376,6 +1391,11 @@ jobs: DAGENT_EXECUTABLE: ${{ steps.load-variables.outputs.dagent-executable }} TARGET_OUTPUT_PATH: ${{ steps.load-variables.outputs.target-output-path }} + # The macos-15 runner is arm64, so it can only install the arm64 package. + - name: Test macOS package installation + if: ${{ matrix.os == 'macos' && matrix.arch == 'arm64' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }} + run: ./package/AgentMacOS/Test-Package.sh "${{ steps.load-variables.outputs.target-output-path }}/devolutions-agent.pkg" + shell: bash - name: Clean up architecture-agnostic files before upload run: | diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index fe3d259465..e37b99450c 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -193,8 +193,6 @@ jobs: exclude: - project: devolutions-gateway os: macos - - project: devolutions-agent - os: macos steps: - name: Checkout ${{ github.repository }} @@ -238,11 +236,21 @@ jobs: security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH security import $DEVELOPER_ID_CERTIFICATE_PATH -P "$DEVELOPER_ID_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH + + # Installer packages need a separate Developer ID Installer identity. + if [ -n "$DEVELOPER_ID_INSTALLER_CERTIFICATE" ]; then + DEVELOPER_ID_INSTALLER_CERTIFICATE_PATH=$RUNNER_TEMP/dev_id_installer_cert.p12 + echo -n "$DEVELOPER_ID_INSTALLER_CERTIFICATE" | base64 --decode --output $DEVELOPER_ID_INSTALLER_CERTIFICATE_PATH + security import $DEVELOPER_ID_INSTALLER_CERTIFICATE_PATH -P "$DEVELOPER_ID_INSTALLER_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH + fi + security list-keychain -d user -s $KEYCHAIN_PATH shell: bash env: DEVELOPER_ID_CERTIFICATE: ${{ secrets.APPLE_APP_DEV_ID_APP_CERTIFICATE }} DEVELOPER_ID_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_APP_DEV_ID_APP_CERTIFICATE_PASSWORD }} + DEVELOPER_ID_INSTALLER_CERTIFICATE: ${{ secrets.APPLE_APP_DEV_ID_INSTALLER_CERTIFICATE }} + DEVELOPER_ID_INSTALLER_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_APP_DEV_ID_INSTALLER_CERTIFICATE_PASSWORD }} - name: Configure runner (Windows) if: ${{ matrix.os == 'windows' }} @@ -333,11 +341,11 @@ jobs: run: | $IncludePattern = @(switch ('${{ matrix.project }}') { 'devolutions-gateway' { @('DevolutionsGateway.exe') } - 'devolutions-agent' { @('DevolutionsAgent.exe', 'DevolutionsAgentUpdater.exe', 'DevolutionsPedmShellExt.dll', 'DevolutionsPedmShellExt.msix', 'DevolutionsDesktopAgent.exe') } + 'devolutions-agent' { @('DevolutionsAgent.exe', 'DevolutionsAgentUpdater.exe', 'DevolutionsPedmShellExt.dll', 'DevolutionsPedmShellExt.msix', 'DevolutionsDesktopAgent.exe', 'devolutions-agent') } 'jetsocat' { @('jetsocat.exe', 'jetsocat') } }) $ExcludePattern = "*.pdb" - Get-ChildItem -Path ${{ runner.temp }} -Recurse -Include $IncludePattern -Exclude $ExcludePattern | % { + Get-ChildItem -Path ${{ runner.temp }} -Recurse -File -Include $IncludePattern -Exclude $ExcludePattern | % { if ('${{ matrix.os }}' -Eq 'windows') { if ($_.Name -Eq 'DevolutionsPedmShellExt.msix') { $PackagePublisher = '${{ secrets.CODE_SIGNING_APPX_PUBLISHER }}' @@ -604,6 +612,52 @@ jobs: } shell: pwsh + - name: Repackage and notarize Agent (macOS) + if: ${{ matrix.project == 'devolutions-agent' && matrix.os == 'macos' }} + run: | + $PackageRoot = Join-Path ${{ runner.temp }} devolutions-agent macos + $IsProduction = '${{ needs.preflight.outputs.package-env }}' -Eq 'publish-prod' + $CanSign = -Not [string]::IsNullOrEmpty($Env:DEVELOPER_ID_INSTALLER_CERTIFICATE) + $CanNotarize = $CanSign -And -Not [string]::IsNullOrEmpty($Env:APPLE_BOT_PASSWORD) + + if (-Not $CanNotarize) { + $Message = 'the Developer ID Installer certificate or the notarization credentials are missing; the Agent macOS package is not notarized' + if ($IsProduction) { throw $Message } + echo "::warning::$Message" + } + + if ($CanSign) { + $Env:DAGENT_PKG_SIGNING_IDENTITY = 'Developer ID Installer: Devolutions inc. (N592S9ASDB)' + } + + if ($CanNotarize) { + xcrun notarytool store-credentials 'notarytool-profile' --apple-id "$Env:APPLE_BOT_ID" --team-id "$Env:APPLE_BOT_TEAM_ID" --password "$Env:APPLE_BOT_PASSWORD" + if ($LastExitCode -Ne 0) { throw 'notarytool store-credentials failed' } + } + + foreach ($Arch in @('x64', 'arm64')) { + $ArchRoot = Join-Path $PackageRoot $Arch + $Env:DAGENT_EXECUTABLE = Join-Path $ArchRoot 'devolutions-agent' + $Env:TARGET_OUTPUT_PATH = $ArchRoot + + Write-Host "=== Repackage Agent ($Arch) ===" + ./ci/tlk.ps1 package -Product agent -Platform macos -Architecture $Arch + + if ($CanNotarize) { + $PackageFile = Join-Path $ArchRoot 'devolutions-agent.pkg' + xcrun notarytool submit $PackageFile --keychain-profile 'notarytool-profile' --wait + if ($LastExitCode -Ne 0) { throw "notarization failed for $PackageFile" } + xcrun stapler staple $PackageFile + if ($LastExitCode -Ne 0) { throw "stapling failed for $PackageFile" } + } + } + shell: pwsh + env: + DEVELOPER_ID_INSTALLER_CERTIFICATE: ${{ secrets.APPLE_APP_DEV_ID_INSTALLER_CERTIFICATE }} + APPLE_BOT_PASSWORD: ${{ secrets.APPLE_BOT_PASSWORD }} + APPLE_BOT_ID: bot@devolutions.net + APPLE_BOT_TEAM_ID: N592S9ASDB + - name: Verification if: ${{ (matrix.os == 'windows' || matrix.os == 'macos') && env.package-env == 'publish-prod' }} run: | @@ -617,13 +671,25 @@ jobs: } } } elseif ('${{ matrix.os }}' -Eq 'macos') { - Get-ChildItem -Path $RootPath -Recurse -Include 'jetsocat' | % { + Get-ChildItem -Path $RootPath -Recurse -File -Include ('jetsocat', 'devolutions-agent') | % { codesign -dvvv "$($_.FullName)" if ($LastExitCode -Ne 0) { echo "::error::failed to verify the signature of $($_.FullName)" exit 1 } } + Get-ChildItem -Path $RootPath -Recurse -File -Include '*.pkg' | % { + pkgutil --check-signature "$($_.FullName)" + if ($LastExitCode -Ne 0) { + echo "::error::failed to verify the signature of $($_.FullName)" + exit 1 + } + xcrun stapler validate "$($_.FullName)" + if ($LastExitCode -Ne 0) { + echo "::error::failed to validate the notarization ticket of $($_.FullName)" + exit 1 + } + } } shell: pwsh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ef534a0731..1f9a572242 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -370,6 +370,11 @@ jobs: Move-Item "$Dl/devolutions-agent/linux/${Arch}/devolutions-agent.changes" "$Release/devolutions-agent-${Version}-${Arch}.changes" } + # Agent macOS PKG + foreach ($Arch in @('x64', 'arm64')) { + Move-Item "$Dl/devolutions-agent/macos/${Arch}/devolutions-agent.pkg" "$Release/devolutions-agent-${Version}-${Arch}.pkg" + } + # Jetsocat archives: .tar.xz for Linux, .zip for Windows and macOS foreach ($P in @( @{ OS = "windows"; Arch = "x64"; Ext = ".exe"; Archive = "zip" }, @@ -658,6 +663,10 @@ jobs: Move-Item -Path "./devolutions-agent/linux/${Arch}/devolutions-agent.rpm" -Destination "$destinationFolder/devolutions-agent-${versionFull}-${Arch}.rpm" Move-Item -Path "./devolutions-agent/linux/${Arch}/devolutions-agent.changes" -Destination "$destinationFolder/devolutions-agent-${versionFull}-${Arch}.changes" } + + foreach ($Arch in @('x64', 'arm64')) { + Move-Item -Path "./devolutions-agent/macos/${Arch}/devolutions-agent.pkg" -Destination "$destinationFolder/devolutions-agent-${versionFull}-${Arch}.pkg" + } shell: pwsh - name: Attest build provenance diff --git a/ci/tlk.ps1 b/ci/tlk.ps1 index fda2c53fae..8a87684236 100755 --- a/ci/tlk.ps1 +++ b/ci/tlk.ps1 @@ -919,6 +919,89 @@ class TlkRecipe Pop-Location } + [void] Package_MacOS() { + if ($this.Product -Ne 'agent') { + throw "macOS packaging for $($this.Product) is not supported" + } + + if (Test-Path Env:DAGENT_EXECUTABLE) { + $Executable = $Env:DAGENT_EXECUTABLE + } else { + throw ("Specify DAGENT_EXECUTABLE environment variable") + } + + $HostArchitecture = switch ($this.Target.Architecture) { + "x64" { "x86_64" } + "arm64" { "arm64" } + default { throw "unsupported macOS architecture: $($this.Target.Architecture)" } + } + + $Identifier = "com.devolutions.agent" + $InputPackagePath = Join-Path $this.SourcePath "package/AgentMacOS" + $OutputPath = Join-Path $this.SourcePath "output/agent-macos-$($this.Target.Architecture)" + $PayloadPath = Join-Path $OutputPath "payload" + $ScriptsPath = Join-Path $OutputPath "scripts" + $BinPath = Join-Path $PayloadPath "usr/local/bin" + $LaunchDaemonsPath = Join-Path $PayloadPath "Library/LaunchDaemons" + + Remove-Item -Path $OutputPath -Recurse -Force -ErrorAction SilentlyContinue + @($BinPath, $LaunchDaemonsPath, $ScriptsPath) | % { + New-Item -Path $_ -ItemType 'Directory' -Force | Out-Null + } + + Copy-Item $Executable "$BinPath/devolutions-agent" + Copy-Item "$InputPackagePath/devolutions-agent-uninstall" "$BinPath/devolutions-agent-uninstall" + Copy-Item "$InputPackagePath/$Identifier.plist" "$LaunchDaemonsPath/$Identifier.plist" + Copy-Item "$InputPackagePath/scripts/*" $ScriptsPath + + & 'chmod' '755' "$BinPath/devolutions-agent" "$BinPath/devolutions-agent-uninstall" "$ScriptsPath/preinstall" "$ScriptsPath/postinstall" + & 'chmod' '644' "$LaunchDaemonsPath/$Identifier.plist" + + & 'plutil' '-lint' "$LaunchDaemonsPath/$Identifier.plist" | Out-Host + if ($LASTEXITCODE -Ne 0) { + throw "invalid launchd plist" + } + + $ComponentPackageName = "devolutions-agent-component.pkg" + + & 'pkgbuild' '--root' $PayloadPath ` + '--scripts' $ScriptsPath ` + '--identifier' $Identifier ` + '--version' $this.Version ` + '--install-location' '/' ` + '--ownership' 'recommended' ` + "$OutputPath/$ComponentPackageName" | Out-Host + if ($LASTEXITCODE -Ne 0) { + throw "pkgbuild failed" + } + + $DistributionFile = Join-Path $OutputPath "distribution.xml" + Merge-Tokens -TemplateFile "$InputPackagePath/distribution.xml" -Tokens @{ + host_arch = $HostArchitecture + version = $this.Version + component_pkg = $ComponentPackageName + } -OutputFile $DistributionFile + + $ProductBuildArgs = @('--distribution', $DistributionFile, '--package-path', $OutputPath) + + # The installer is signed here because a signature cannot be added to a built product archive. + if (Test-Path Env:DAGENT_PKG_SIGNING_IDENTITY) { + $ProductBuildArgs += @('--sign', $Env:DAGENT_PKG_SIGNING_IDENTITY, '--timestamp') + } + + $PackageFile = Join-Path $OutputPath "devolutions-agent.pkg" + & 'productbuild' @ProductBuildArgs $PackageFile | Out-Host + if ($LASTEXITCODE -Ne 0) { + throw "productbuild failed" + } + + if (Test-Path Env:TARGET_OUTPUT_PATH) { + $TargetOutputPath = $Env:TARGET_OUTPUT_PATH + New-Item -Path $TargetOutputPath -ItemType 'Directory' -Force | Out-Null + Copy-Item $PackageFile "$TargetOutputPath/devolutions-agent.pkg" -Force + } + } + [void] Package([string]$PackageOption) { if ($this.Product -Eq 'jetsocat') { throw "Packaging for $($this.Product) is not supported" @@ -943,6 +1026,8 @@ class TlkRecipe } } elseif ($this.Target.IsLinux()) { $this.Package_Linux() + } elseif ($this.Target.IsMacOS()) { + $this.Package_MacOS() } } diff --git a/devolutions-agent/src/cli.rs b/devolutions-agent/src/cli.rs index 64bfa8d7d2..06f21855f5 100644 --- a/devolutions-agent/src/cli.rs +++ b/devolutions-agent/src/cli.rs @@ -13,7 +13,13 @@ use devolutions_agent::AgentServiceEvent; use devolutions_agent::config::ConfHandle; use devolutions_agent::enrollment::parse_enrollment_jwt; -use crate::service::{DESCRIPTION, DISPLAY_NAME, SERVICE_NAME}; +use crate::service::{DESCRIPTION, DISPLAY_NAME}; + +/// The launchd label, which must match the LaunchDaemon plist that the macOS package installs. +#[cfg(target_os = "macos")] +const CONTROLLER_NAME: &str = "com.devolutions.agent"; +#[cfg(not(target_os = "macos"))] +const CONTROLLER_NAME: &str = crate::service::SERVICE_NAME; #[derive(Debug, PartialEq, Eq)] struct UpCommand { @@ -97,7 +103,7 @@ fn parse_up_command_args_with_reader(args: &[String], mut stdin_read } pub(crate) fn run() { - let mut controller = Controller::new(SERVICE_NAME, DISPLAY_NAME, DESCRIPTION); + let mut controller = Controller::new(CONTROLLER_NAME, DISPLAY_NAME, DESCRIPTION); if let Some(cmd) = env::args().nth(1) { match cmd.as_str() { diff --git a/package/AgentMacOS/README.md b/package/AgentMacOS/README.md new file mode 100644 index 0000000000..01a295e28c --- /dev/null +++ b/package/AgentMacOS/README.md @@ -0,0 +1,48 @@ +# Devolutions Agent for macOS + +The installer package runs Devolutions Agent as the `com.devolutions.agent` launchd daemon, as root. +There is one package per architecture; install the one that matches the Mac. + +```sh +sudo installer -pkg devolutions-agent--arm64.pkg -target / +``` + +The package installs these files: + +| Path | Content | +| --- | --- | +| `/usr/local/bin/devolutions-agent` | Agent executable | +| `/usr/local/bin/devolutions-agent-uninstall` | Uninstall script | +| `/Library/LaunchDaemons/com.devolutions.agent.plist` | launchd daemon definition | +| `/Library/Application Support/Devolutions Agent` | Configuration and credentials, readable by root only | +| `/Library/Logs/Devolutions Agent` | Standard output and error of the daemon | + +The Agent writes its own log files to the configuration directory. +Installing creates a default `agent.json` when none exists, then starts the daemon; launchd restarts it after a crash and at boot. +Upgrading keeps the existing configuration. + +Start and stop the daemon with the Agent itself or with `launchctl`: + +```sh +sudo devolutions-agent stop +sudo devolutions-agent start +sudo launchctl kickstart -k system/com.devolutions.agent +``` + +PowerShell Universal jobs need PowerShell 7, found through the daemon's `PATH`, which includes `/usr/local/bin` and `/opt/homebrew/bin`. + +Uninstall the Agent and keep its configuration, or remove everything with `--purge`: + +```sh +sudo devolutions-agent-uninstall +sudo devolutions-agent-uninstall --purge +``` + +Build a package from a built executable on macOS: + +```sh +DAGENT_EXECUTABLE=target/release/devolutions-agent pwsh ./ci/tlk.ps1 package -Product agent -Platform macos -Architecture arm64 +``` + +Set `DAGENT_PKG_SIGNING_IDENTITY` to a Developer ID Installer identity to sign the package. +`Test-Package.sh` installs a package, checks the daemon, upgrades, and uninstalls it; run it only on a disposable machine. diff --git a/package/AgentMacOS/Test-Package.sh b/package/AgentMacOS/Test-Package.sh new file mode 100755 index 0000000000..be1226ce64 --- /dev/null +++ b/package/AgentMacOS/Test-Package.sh @@ -0,0 +1,64 @@ +#!/bin/sh + +# Installs, upgrades, and uninstalls the Devolutions Agent macOS package, checking the launchd service at each step. +# Changes the system it runs on, so only run it on a disposable machine such as a CI runner. + +set -eu + +PKG="$1" +LABEL="com.devolutions.agent" +PLIST="/Library/LaunchDaemons/$LABEL.plist" +DATA_DIR="/Library/Application Support/Devolutions Agent" +LOG_DIR="/Library/Logs/Devolutions Agent" + +fail() { + echo "FAIL: $*" >&2 + sudo /bin/launchctl print "system/$LABEL" >&2 || true + sudo cat "$LOG_DIR/agent.stderr.log" >&2 || true + exit 1 +} + +# Polls because launchd starts and stops the job asynchronously. +wait_for_state() { + expected="$1" + for _ in $(seq 1 30); do + state="$(sudo /bin/launchctl print "system/$LABEL" 2>/dev/null | awk '/^\tstate = / { sub(/^\tstate = /, ""); print; exit }')" + [ "$state" = "$expected" ] && return 0 + sleep 1 + done + fail "expected the service to be $expected, got '${state:-not loaded}'" +} + +install_pkg() { + sudo /usr/sbin/installer -pkg "$PKG" -target / || fail "installer failed" +} + +echo "Installing $PKG" +install_pkg + +[ -x /usr/local/bin/devolutions-agent ] || fail "binary not installed" +[ -x /usr/local/bin/devolutions-agent-uninstall ] || fail "uninstall script not installed" +[ "$(stat -f '%Su:%Sg %Lp' "$PLIST")" = "root:wheel 644" ] || fail "unexpected plist ownership or mode" +[ "$(sudo stat -f '%Su %Lp' "$DATA_DIR")" = "root 700" ] || fail "unexpected data directory ownership or mode" +sudo test -f "$DATA_DIR/agent.json" || fail "configuration not initialized" +wait_for_state running + +echo "Stopping and starting through the agent command line" +sudo /usr/local/bin/devolutions-agent stop +wait_for_state "not running" +sudo /usr/local/bin/devolutions-agent start +wait_for_state running + +echo "Upgrading over the installed package" +install_pkg +wait_for_state running + +echo "Uninstalling" +sudo /usr/local/bin/devolutions-agent-uninstall --purge +sudo /bin/launchctl print "system/$LABEL" >/dev/null 2>&1 && fail "service still loaded after uninstall" +for path in /usr/local/bin/devolutions-agent /usr/local/bin/devolutions-agent-uninstall "$PLIST" "$DATA_DIR" "$LOG_DIR"; do + [ -e "$path" ] && fail "$path still present after uninstall" +done +/usr/sbin/pkgutil --pkg-info "$LABEL" >/dev/null 2>&1 && fail "package receipt still present after uninstall" + +echo "PASS" diff --git a/package/AgentMacOS/com.devolutions.agent.plist b/package/AgentMacOS/com.devolutions.agent.plist new file mode 100644 index 0000000000..73b96bad3f --- /dev/null +++ b/package/AgentMacOS/com.devolutions.agent.plist @@ -0,0 +1,30 @@ + + + + + Label + com.devolutions.agent + ProgramArguments + + /usr/local/bin/devolutions-agent + + EnvironmentVariables + + PATH + /usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin + + RunAtLoad + + KeepAlive + + SuccessfulExit + + + ExitTimeOut + 45 + StandardOutPath + /Library/Logs/Devolutions Agent/agent.stdout.log + StandardErrorPath + /Library/Logs/Devolutions Agent/agent.stderr.log + + diff --git a/package/AgentMacOS/devolutions-agent-uninstall b/package/AgentMacOS/devolutions-agent-uninstall new file mode 100755 index 0000000000..f27c3d9d62 --- /dev/null +++ b/package/AgentMacOS/devolutions-agent-uninstall @@ -0,0 +1,39 @@ +#!/bin/sh + +# Removes Devolutions Agent. +# The configuration, credentials, and logs are kept unless --purge is given. + +set -eu + +LABEL="com.devolutions.agent" +PLIST="/Library/LaunchDaemons/$LABEL.plist" +DATA_DIR="/Library/Application Support/Devolutions Agent" +LOG_DIR="/Library/Logs/Devolutions Agent" + +PURGE=0 +case "${1:-}" in + "") ;; + --purge) PURGE=1 ;; + *) + echo "usage: $0 [--purge]" >&2 + exit 2 + ;; +esac + +if [ "$(/usr/bin/id -u)" -ne 0 ]; then + echo "$0 must run as root, for example with sudo" >&2 + exit 1 +fi + +if /bin/launchctl print "system/$LABEL" >/dev/null 2>&1; then + /bin/launchctl bootout "system/$LABEL" +fi + +/bin/rm -f "$PLIST" /usr/local/bin/devolutions-agent +/usr/sbin/pkgutil --forget "$LABEL" >/dev/null 2>&1 || true + +if [ "$PURGE" -eq 1 ]; then + /bin/rm -rf "$DATA_DIR" "$LOG_DIR" +fi + +/bin/rm -f /usr/local/bin/devolutions-agent-uninstall diff --git a/package/AgentMacOS/distribution.xml b/package/AgentMacOS/distribution.xml new file mode 100644 index 0000000000..43515fef46 --- /dev/null +++ b/package/AgentMacOS/distribution.xml @@ -0,0 +1,20 @@ + + + Devolutions Agent + com.devolutions + + + + + + + + + + + + + + + {{ component_pkg }} + diff --git a/package/AgentMacOS/scripts/postinstall b/package/AgentMacOS/scripts/postinstall new file mode 100755 index 0000000000..2387ecd19d --- /dev/null +++ b/package/AgentMacOS/scripts/postinstall @@ -0,0 +1,24 @@ +#!/bin/sh + +set -e + +LABEL="com.devolutions.agent" +PLIST="/Library/LaunchDaemons/$LABEL.plist" +DATA_DIR="/Library/Application Support/Devolutions Agent" +LOG_DIR="/Library/Logs/Devolutions Agent" + +# The data directory holds the configuration and credentials, so only root may read it. +if [ ! -d "$DATA_DIR" ]; then + /bin/mkdir -p "$DATA_DIR" + /bin/chmod 700 "$DATA_DIR" +fi + +/bin/mkdir -p "$LOG_DIR" + +# A broken existing configuration must not block the upgrade that may fix it. +/usr/local/bin/devolutions-agent config init >/dev/null || echo "devolutions-agent: failed to initialize the configuration" >&2 + +/bin/launchctl enable "system/$LABEL" +/bin/launchctl bootstrap system "$PLIST" + +exit 0 diff --git a/package/AgentMacOS/scripts/preinstall b/package/AgentMacOS/scripts/preinstall new file mode 100755 index 0000000000..fdfa45b604 --- /dev/null +++ b/package/AgentMacOS/scripts/preinstall @@ -0,0 +1,18 @@ +#!/bin/sh + +# Stops a running Agent so the payload can replace its binary. + +LABEL="com.devolutions.agent" + +if /bin/launchctl print "system/$LABEL" >/dev/null 2>&1; then + /bin/launchctl bootout "system/$LABEL" || true + + # postinstall cannot bootstrap the label again until launchd has fully unloaded it. + i=0 + while /bin/launchctl print "system/$LABEL" >/dev/null 2>&1 && [ "$i" -lt 60 ]; do + /bin/sleep 1 + i=$((i + 1)) + done +fi + +exit 0 From 8317613341a8eeecadee1f5226ac796f648683c3 Mon Sep 17 00:00:00 2001 From: Marc-Andre Moreau Date: Mon, 9 Nov 2026 16:53:20 -0500 Subject: [PATCH 2/3] ci(package): verify macOS package signing Reuse the existing Developer ID Application and Apple notarization secrets. Add separate Installer certificate secrets for the Agent package, and verify both architecture binaries and packages in production. Fail macOS signing errors and document the required environment secrets. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/README.md | 15 ++++++++- .github/workflows/package.yml | 57 ++++++++++++++++++----------------- 2 files changed, 44 insertions(+), 28 deletions(-) diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 3f61bd176e..10c37ed01f 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -57,7 +57,20 @@ The workflow will display a notice if `run` was not built from the main branch. - The `Codesign` job uses a matrix to code-sign and repackage in parallel. It's important to ensure that individual matrix jobs do not upload the same artifacts; otherwise the result may be unexpected. For example: each platform downloads the `jetsocat` artifact, which contains builds for several operating systems. The Windows job must only sign and upload the Windows builds. If it were allowed to upload the macOS builds, they will be unsigned and *may* overwrite the signed builds uploaded by the macOS job. - The workflow makes a checkout of the repository at the commit that `run` was built from. This ensures consistency with the [CI](#ci) workflow when repackaging. Remember that tools invoked by the workflow (e.g. [tlk.ps1](../../ci/tlk.ps1)) will be from that commit too. -- The Devolutions Agent macOS installer is signed with the `APPLE_APP_DEV_ID_INSTALLER_CERTIFICATE` secret, a base64 Developer ID Installer PKCS #12, and its `APPLE_APP_DEV_ID_INSTALLER_CERTIFICATE_PASSWORD`, then notarized with `APPLE_BOT_PASSWORD`. Without them, the package stays unsigned, the job warns, and `publish-prod` fails. + +For macOS, make these GitHub Actions secrets available to the `publish-prod` environment: + +| Secret | Purpose | +| --- | --- | +| `APPLE_APP_DEV_ID_APP_CERTIFICATE` | Developer ID Application certificate | +| `APPLE_APP_DEV_ID_APP_CERTIFICATE_PASSWORD` | Application certificate export password | +| `APPLE_APP_DEV_ID_INSTALLER_CERTIFICATE` | Developer ID Installer certificate for the Agent package | +| `APPLE_APP_DEV_ID_INSTALLER_CERTIFICATE_PASSWORD` | Installer certificate export password | +| `APPLE_BOT_PASSWORD` | App-specific Apple ID password for notarization | + +Both certificates must be base64-encoded PKCS #12 bundles containing their private keys. +The Agent package requires a separate Developer ID Installer certificate. +Production packaging fails if the Installer certificate or notarization password is missing. ##### TODO diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index e37b99450c..5f27ac059d 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -238,7 +238,9 @@ jobs: security import $DEVELOPER_ID_CERTIFICATE_PATH -P "$DEVELOPER_ID_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH # Installer packages need a separate Developer ID Installer identity. - if [ -n "$DEVELOPER_ID_INSTALLER_CERTIFICATE" ]; then + # UNTESTED: productbuild needs prompt-free access to the Installer key for CI; + # verify a signed package after changing the keychain setup. + if [ '${{ matrix.project }}' = 'devolutions-agent' ] && [ -n "$DEVELOPER_ID_INSTALLER_CERTIFICATE" ]; then DEVELOPER_ID_INSTALLER_CERTIFICATE_PATH=$RUNNER_TEMP/dev_id_installer_cert.p12 echo -n "$DEVELOPER_ID_INSTALLER_CERTIFICATE" | base64 --decode --output $DEVELOPER_ID_INSTALLER_CERTIFICATE_PATH security import $DEVELOPER_ID_INSTALLER_CERTIFICATE_PATH -P "$DEVELOPER_ID_INSTALLER_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH @@ -345,11 +347,11 @@ jobs: 'jetsocat' { @('jetsocat.exe', 'jetsocat') } }) $ExcludePattern = "*.pdb" - Get-ChildItem -Path ${{ runner.temp }} -Recurse -File -Include $IncludePattern -Exclude $ExcludePattern | % { + Get-ChildItem -Path $Env:RUNNER_TEMP -Recurse -File -Include $IncludePattern -Exclude $ExcludePattern | % { if ('${{ matrix.os }}' -Eq 'windows') { if ($_.Name -Eq 'DevolutionsPedmShellExt.msix') { $PackagePublisher = '${{ secrets.CODE_SIGNING_APPX_PUBLISHER }}' - $UnpackedMsix = Join-Path ${{ runner.temp }} "unpacked-context-menu-msix" + $UnpackedMsix = Join-Path $Env:RUNNER_TEMP "unpacked-context-menu-msix" $AppxManifest = Join-Path $UnpackedMsix "AppxManifest.xml" $PackedMsix = $_.FullName @@ -378,16 +380,10 @@ jobs: '-v') AzureSignTool @Params $_.FullName } elseif ('${{ matrix.os }}' -Eq 'macos') { - $SignCmd = $(@( - 'codesign', - '--timestamp', - '--options=runtime', - '-s', '"Developer ID Application: Devolutions inc. (N592S9ASDB)"', - '-v', - $_.FullName - )) -Join ' ' - Write-Host $SignCmd - Invoke-Expression $SignCmd + codesign --force --timestamp --options=runtime -s 'Developer ID Application: Devolutions inc. (N592S9ASDB)' -v $_.FullName + if ($LastExitCode -Ne 0) { + throw "failed to sign $($_.FullName)" + } } else { echo "::debug::nothing to do for ${{ matrix.os }}" } @@ -615,7 +611,7 @@ jobs: - name: Repackage and notarize Agent (macOS) if: ${{ matrix.project == 'devolutions-agent' && matrix.os == 'macos' }} run: | - $PackageRoot = Join-Path ${{ runner.temp }} devolutions-agent macos + $PackageRoot = Join-Path $Env:RUNNER_TEMP devolutions-agent macos $IsProduction = '${{ needs.preflight.outputs.package-env }}' -Eq 'publish-prod' $CanSign = -Not [string]::IsNullOrEmpty($Env:DEVELOPER_ID_INSTALLER_CERTIFICATE) $CanNotarize = $CanSign -And -Not [string]::IsNullOrEmpty($Env:APPLE_BOT_PASSWORD) @@ -658,6 +654,25 @@ jobs: APPLE_BOT_ID: bot@devolutions.net APPLE_BOT_TEAM_ID: N592S9ASDB + - name: Verify macOS Agent signatures and notarization + if: ${{ matrix.project == 'devolutions-agent' && matrix.os == 'macos' && needs.preflight.outputs.package-env == 'publish-prod' }} + run: | + $PackageRoot = Join-Path $Env:RUNNER_TEMP devolutions-agent macos + foreach ($Arch in @('x64', 'arm64')) { + $Executable = Join-Path $PackageRoot $Arch 'devolutions-agent' + $Package = Join-Path $PackageRoot $Arch 'devolutions-agent.pkg' + + codesign --verify --strict --verbose=2 $Executable + if ($LastExitCode -Ne 0) { throw "failed to verify the signature of $Executable" } + + pkgutil --check-signature $Package + if ($LastExitCode -Ne 0) { throw "failed to verify the signature of $Package" } + + xcrun stapler validate $Package + if ($LastExitCode -Ne 0) { throw "failed to validate the notarization ticket of $Package" } + } + shell: pwsh + - name: Verification if: ${{ (matrix.os == 'windows' || matrix.os == 'macos') && env.package-env == 'publish-prod' }} run: | @@ -671,25 +686,13 @@ jobs: } } } elseif ('${{ matrix.os }}' -Eq 'macos') { - Get-ChildItem -Path $RootPath -Recurse -File -Include ('jetsocat', 'devolutions-agent') | % { + Get-ChildItem -Path $RootPath -Recurse -File -Include 'jetsocat' | % { codesign -dvvv "$($_.FullName)" if ($LastExitCode -Ne 0) { echo "::error::failed to verify the signature of $($_.FullName)" exit 1 } } - Get-ChildItem -Path $RootPath -Recurse -File -Include '*.pkg' | % { - pkgutil --check-signature "$($_.FullName)" - if ($LastExitCode -Ne 0) { - echo "::error::failed to verify the signature of $($_.FullName)" - exit 1 - } - xcrun stapler validate "$($_.FullName)" - if ($LastExitCode -Ne 0) { - echo "::error::failed to validate the notarization ticket of $($_.FullName)" - exit 1 - } - } } shell: pwsh From 0b1f1d9e57a6166d1c2121a5ad11eb40393f4ade Mon Sep 17 00:00:00 2001 From: Marc-Andre Moreau Date: Mon, 9 Nov 2026 16:55:59 -0500 Subject: [PATCH 3/3] ci(package): use configured Installer secrets Reference the Developer ID Installer certificate and password under the names available to the package workflow. Update the signing documentation to match. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/README.md | 4 ++-- .github/workflows/package.yml | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 10c37ed01f..24449d15fd 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -64,8 +64,8 @@ For macOS, make these GitHub Actions secrets available to the `publish-prod` env | --- | --- | | `APPLE_APP_DEV_ID_APP_CERTIFICATE` | Developer ID Application certificate | | `APPLE_APP_DEV_ID_APP_CERTIFICATE_PASSWORD` | Application certificate export password | -| `APPLE_APP_DEV_ID_INSTALLER_CERTIFICATE` | Developer ID Installer certificate for the Agent package | -| `APPLE_APP_DEV_ID_INSTALLER_CERTIFICATE_PASSWORD` | Installer certificate export password | +| `APPLE_DEV_ID_INSTALLER_CERTIFICATE` | Developer ID Installer certificate for the Agent package | +| `APPLE_DEV_ID_INSTALLER_CERTIFICATE_PASSWORD` | Installer certificate export password | | `APPLE_BOT_PASSWORD` | App-specific Apple ID password for notarization | Both certificates must be base64-encoded PKCS #12 bundles containing their private keys. diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index 5f27ac059d..354dc7f82f 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -251,8 +251,8 @@ jobs: env: DEVELOPER_ID_CERTIFICATE: ${{ secrets.APPLE_APP_DEV_ID_APP_CERTIFICATE }} DEVELOPER_ID_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_APP_DEV_ID_APP_CERTIFICATE_PASSWORD }} - DEVELOPER_ID_INSTALLER_CERTIFICATE: ${{ secrets.APPLE_APP_DEV_ID_INSTALLER_CERTIFICATE }} - DEVELOPER_ID_INSTALLER_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_APP_DEV_ID_INSTALLER_CERTIFICATE_PASSWORD }} + DEVELOPER_ID_INSTALLER_CERTIFICATE: ${{ secrets.APPLE_DEV_ID_INSTALLER_CERTIFICATE }} + DEVELOPER_ID_INSTALLER_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_DEV_ID_INSTALLER_CERTIFICATE_PASSWORD }} - name: Configure runner (Windows) if: ${{ matrix.os == 'windows' }} @@ -649,7 +649,7 @@ jobs: } shell: pwsh env: - DEVELOPER_ID_INSTALLER_CERTIFICATE: ${{ secrets.APPLE_APP_DEV_ID_INSTALLER_CERTIFICATE }} + DEVELOPER_ID_INSTALLER_CERTIFICATE: ${{ secrets.APPLE_DEV_ID_INSTALLER_CERTIFICATE }} APPLE_BOT_PASSWORD: ${{ secrets.APPLE_BOT_PASSWORD }} APPLE_BOT_ID: bot@devolutions.net APPLE_BOT_TEAM_ID: N592S9ASDB