From 9a5af6ff7965998aa49ff97d89e48ea9712260b4 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 16:01:48 +0100 Subject: [PATCH] Keep Windows agent update signatures addressable Change-source: pulse-maintainer --- .../workflows/qualify-release-containers.yml | 9 +++ Dockerfile | 15 +++- .../subsystems/deployment-installability.md | 7 ++ internal/api/unified_agent_test.go | 26 +++++++ scripts/build-release.sh | 16 ++-- .../installtests/build_release_assets_test.go | 76 +++++++++++++++++-- scripts/prepare-release-container-context.sh | 26 ++++--- scripts/release_asset_common.sh | 22 ++++-- scripts/validate-release.sh | 2 +- 9 files changed, 162 insertions(+), 37 deletions(-) diff --git a/.github/workflows/qualify-release-containers.yml b/.github/workflows/qualify-release-containers.yml index 807305ba9..95fc6212e 100644 --- a/.github/workflows/qualify-release-containers.yml +++ b/.github/workflows/qualify-release-containers.yml @@ -98,6 +98,15 @@ jobs: set -euo pipefail test "$(readlink /usr/local/bin/pulse-agent)" = "/opt/pulse/bin/pulse-agent-linux-amd64" test -x /usr/local/bin/pulse-agent + for arch in amd64 arm64 386; do + for suffix in "" .sig .sshsig; do + alias="/opt/pulse/bin/pulse-agent-windows-${arch}${suffix}" + target="pulse-agent-windows-${arch}.exe${suffix}" + test -L "${alias}" + test "$(readlink "${alias}")" = "${target}" + test -s "${alias}" + done + done for sidecar in /opt/pulse/bin/*.sig /opt/pulse/bin/*.sshsig; do test ! -x "$sidecar" done diff --git a/Dockerfile b/Dockerfile index 6944dac00..0f655fc3b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -369,10 +369,17 @@ RUN if [ "$TARGETARCH" = "arm64" ]; then \ # Unified agent binaries (all platforms and architectures) plus detached signatures COPY --from=release-assets-builder /app/pulse-agent-* /opt/pulse/bin/ -# Create symlinks for Windows without .exe extension +# Create complete binary/signature aliases for Windows without .exe extension. +# The download handler resolves this name and reads detached sidecars beside it. RUN ln -s pulse-agent-windows-amd64.exe /opt/pulse/bin/pulse-agent-windows-amd64 && \ + ln -s pulse-agent-windows-amd64.exe.sig /opt/pulse/bin/pulse-agent-windows-amd64.sig && \ + ln -s pulse-agent-windows-amd64.exe.sshsig /opt/pulse/bin/pulse-agent-windows-amd64.sshsig && \ ln -s pulse-agent-windows-arm64.exe /opt/pulse/bin/pulse-agent-windows-arm64 && \ + ln -s pulse-agent-windows-arm64.exe.sig /opt/pulse/bin/pulse-agent-windows-arm64.sig && \ + ln -s pulse-agent-windows-arm64.exe.sshsig /opt/pulse/bin/pulse-agent-windows-arm64.sshsig && \ ln -s pulse-agent-windows-386.exe /opt/pulse/bin/pulse-agent-windows-386 && \ + ln -s pulse-agent-windows-386.exe.sig /opt/pulse/bin/pulse-agent-windows-386.sig && \ + ln -s pulse-agent-windows-386.exe.sshsig /opt/pulse/bin/pulse-agent-windows-386.sshsig && \ chown -R pulse:pulse /opt/pulse # Arch-resolved /usr/local/bin/pulse-agent so the helm chart's agent workload @@ -411,8 +418,14 @@ RUN chmod 755 /opt/pulse/scripts/*.sh /opt/pulse/scripts/*.ps1 && \ ln -sf /opt/pulse/bin/pulse-agent-linux-amd64 /usr/local/bin/pulse-agent; \ fi && \ ln -sf pulse-agent-windows-amd64.exe /opt/pulse/bin/pulse-agent-windows-amd64 && \ + ln -sf pulse-agent-windows-amd64.exe.sig /opt/pulse/bin/pulse-agent-windows-amd64.sig && \ + ln -sf pulse-agent-windows-amd64.exe.sshsig /opt/pulse/bin/pulse-agent-windows-amd64.sshsig && \ ln -sf pulse-agent-windows-arm64.exe /opt/pulse/bin/pulse-agent-windows-arm64 && \ + ln -sf pulse-agent-windows-arm64.exe.sig /opt/pulse/bin/pulse-agent-windows-arm64.sig && \ + ln -sf pulse-agent-windows-arm64.exe.sshsig /opt/pulse/bin/pulse-agent-windows-arm64.sshsig && \ ln -sf pulse-agent-windows-386.exe /opt/pulse/bin/pulse-agent-windows-386 && \ + ln -sf pulse-agent-windows-386.exe.sig /opt/pulse/bin/pulse-agent-windows-386.sig && \ + ln -sf pulse-agent-windows-386.exe.sshsig /opt/pulse/bin/pulse-agent-windows-386.sshsig && \ chown -R pulse:pulse /opt/pulse # Unified Agent image assembled from the same immutable candidate payload. diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index c363bf037..230846180 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -987,6 +987,13 @@ artifact-selection behaviour. against their exact `.exe` targets and then omit them, because the image recreates those aliases deterministically and the immutable payload manifest remains regular-file-only. + Each alias is a three-member delivery contract: the extensionless binary, + `.sig`, and `.sshsig` paths must resolve respectively to the signed `.exe`, + `.exe.sig`, and `.exe.sshsig` files. Server archives must create those + aliases only after signing the complete cross-platform payload, and both + source-built and exact-candidate images must recreate and qualify all three; + otherwise the download handler rejects the local Windows agent and falls + through to a release asset with the wrong filename identity. Helm Pages convergence must promote the immutable chart artifact produced and qualified by the exact create-release run. It must bind that artifact to the activated source run, tag, commit, and activation marker, and must diff --git a/internal/api/unified_agent_test.go b/internal/api/unified_agent_test.go index 1036856dd..3fe190545 100644 --- a/internal/api/unified_agent_test.go +++ b/internal/api/unified_agent_test.go @@ -206,6 +206,32 @@ func TestDownloadUnifiedAgent_LocalReleaseBinaryIncludesSignatureHeader(t *testi assert.Equal(t, encodedTestSSHSignature("signed-local-agent-ssh"), w.Header().Get(sshSignatureHeaderName)) } +func TestDownloadUnifiedAgent_WindowsReleaseAliasIncludesSignatureHeaders(t *testing.T) { + router, tempDir := setupUnifiedAgentRouter(t) + router.serverVersion = "v6.4.2" + router.installScriptClient = newTestInstallScriptClient(t, http.MethodGet, "", 0, "", errors.New("GitHub fallback must not be used")) + + binDir := filepath.Join(tempDir, "bin") + baseName := "pulse-agent-windows-amd64" + exePath := filepath.Join(binDir, baseName+".exe") + binContent := validTestUnifiedAgentBinary("windows-amd64") + require.NoError(t, os.WriteFile(exePath, binContent, 0755)) + require.NoError(t, os.WriteFile(exePath+".sig", []byte("signed-windows-agent"), 0644)) + require.NoError(t, os.WriteFile(exePath+".sshsig", []byte("signed-windows-agent-ssh"), 0644)) + for _, suffix := range []string{"", ".sig", ".sshsig"} { + require.NoError(t, os.Symlink(baseName+".exe"+suffix, filepath.Join(binDir, baseName+suffix))) + } + + req := httptest.NewRequest(http.MethodGet, "/download/pulse-agent?arch=windows-amd64", nil) + w := httptest.NewRecorder() + router.handleDownloadUnifiedAgent(w, req) + + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + assert.Equal(t, string(binContent), w.Body.String()) + assert.Equal(t, "signed-windows-agent", w.Header().Get(signatureHeaderName)) + assert.Equal(t, encodedTestSSHSignature("signed-windows-agent-ssh"), w.Header().Get(sshSignatureHeaderName)) +} + func TestDownloadUnifiedAgent_SkipsStaleLocalBinaryAndProxies(t *testing.T) { router, tempDir := setupUnifiedAgentRouter(t) router.serverVersion = "v6.0.0-rc.1" diff --git a/scripts/build-release.sh b/scripts/build-release.sh index 30295db73..dc5c97e21 100755 --- a/scripts/build-release.sh +++ b/scripts/build-release.sh @@ -396,11 +396,9 @@ esac EOF chmod +x "$universal_dir/bin/pulse-agent" -# Add VERSION file +# Add VERSION file. Sign the completed universal payload only after every +# cross-platform agent has been staged below. echo "$VERSION" > "$universal_dir/VERSION" -pulse_release_sign_directory_assets "$universal_dir/bin" -pulse_release_sign_directory_assets "$universal_dir/scripts" -pulse_release_sign_file "$universal_dir/VERSION" # Package standalone unified agent binaries (all platforms) # Linux @@ -500,10 +498,12 @@ cp "$BUILD_DIR/pulse-agent-windows-amd64.exe" "$universal_dir/bin/" cp "$BUILD_DIR/pulse-agent-windows-arm64.exe" "$universal_dir/bin/" cp "$BUILD_DIR/pulse-agent-windows-386.exe" "$universal_dir/bin/" -# Create symlinks for Windows binaries without .exe extension (required for download endpoint) -ln -s pulse-agent-windows-amd64.exe "$universal_dir/bin/pulse-agent-windows-amd64" -ln -s pulse-agent-windows-arm64.exe "$universal_dir/bin/pulse-agent-windows-arm64" -ln -s pulse-agent-windows-386.exe "$universal_dir/bin/pulse-agent-windows-386" +# Sign all regular payload files, then create the extensionless Windows aliases +# as complete binary/signature triplets required by the download endpoint. +pulse_release_sign_directory_assets "$universal_dir/bin" +pulse_release_sign_directory_assets "$universal_dir/scripts" +pulse_release_sign_file "$universal_dir/VERSION" +pulse_release_link_windows_agent_aliases "$universal_dir/bin" # Create universal tarball cd "$universal_dir" diff --git a/scripts/installtests/build_release_assets_test.go b/scripts/installtests/build_release_assets_test.go index cc1dc385d..5e747e22e 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -509,6 +509,9 @@ func TestReleaseContainerTargetsConsumeImmutableCandidate(t *testing.T) { `test "${actual_embedded_agent}" = "${expected_agent}"`, `test "$(readlink /usr/local/bin/pulse-agent)" = "/opt/pulse/bin/pulse-agent-linux-amd64"`, `test -x /usr/local/bin/pulse-agent`, + `for arch in amd64 arm64 386; do`, + `target="pulse-agent-windows-${arch}.exe${suffix}"`, + `test -s "${alias}"`, `test ! -x "$sidecar"`, } { if !strings.Contains(qualifier, needle) { @@ -517,6 +520,54 @@ func TestReleaseContainerTargetsConsumeImmutableCandidate(t *testing.T) { } } +func TestWindowsAgentAliasesCarryDetachedSignatureSidecars(t *testing.T) { + commonPath := repoFile("scripts", "release_asset_common.sh") + tempDir := t.TempDir() + for _, arch := range []string{"amd64", "arm64", "386"} { + base := filepath.Join(tempDir, "pulse-agent-windows-"+arch+".exe") + for _, suffix := range []string{"", ".sig", ".sshsig"} { + if err := os.WriteFile(base+suffix, []byte("packet-"+arch+suffix), 0o644); err != nil { + t.Fatalf("write Windows packet fixture: %v", err) + } + } + } + cmd := exec.Command("bash", "-c", `source "$1"; pulse_release_link_windows_agent_aliases "$2"`, "windows-agent-alias-test", commonPath, tempDir) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("create Windows agent packet aliases: %v\n%s", err, output) + } + for _, arch := range []string{"amd64", "arm64", "386"} { + for _, suffix := range []string{"", ".sig", ".sshsig"} { + alias := filepath.Join(tempDir, "pulse-agent-windows-"+arch+suffix) + want := "pulse-agent-windows-" + arch + ".exe" + suffix + got, err := os.Readlink(alias) + if err != nil { + t.Fatalf("read Windows agent alias %s: %v", alias, err) + } + if got != want { + t.Fatalf("Windows agent alias %s targets %q, want %q", alias, got, want) + } + if info, err := os.Stat(alias); err != nil || info.Size() == 0 { + t.Fatalf("Windows agent alias %s does not resolve to a non-empty packet member: info=%v err=%v", alias, info, err) + } + } + } + + assertFileContainsAll(t, repoFile("Dockerfile"), + `ln -s pulse-agent-windows-amd64.exe.sig /opt/pulse/bin/pulse-agent-windows-amd64.sig`, + `ln -s pulse-agent-windows-amd64.exe.sshsig /opt/pulse/bin/pulse-agent-windows-amd64.sshsig`, + `ln -sf pulse-agent-windows-amd64.exe.sig /opt/pulse/bin/pulse-agent-windows-amd64.sig`, + `ln -sf pulse-agent-windows-amd64.exe.sshsig /opt/pulse/bin/pulse-agent-windows-amd64.sshsig`, + ) + assertFileContainsAll(t, repoFile("scripts", "build-release.sh"), + `pulse_release_sign_directory_assets "$universal_dir/bin"`, + `pulse_release_link_windows_agent_aliases "$universal_dir/bin"`, + ) + assertFileContainsAll(t, repoFile("scripts", "prepare-release-container-context.sh"), + `for suffix in "" .sig .sshsig; do`, + `expected_target="pulse-agent-windows-${windows_arch}.exe${suffix}"`, + ) +} + func TestReleaseContainerContextTreatsServerSignaturesAsArchitectureBound(t *testing.T) { version := "6.3.0-rc.test" releaseDir := t.TempDir() @@ -548,6 +599,11 @@ func TestReleaseContainerContextTreatsServerSignaturesAsArchitectureBound(t *tes "scripts/install.sh.sshsig": "shared-installer-sshsig", "VERSION": version, } + for _, windowsArch := range []string{"amd64", "arm64", "386"} { + name := "bin/pulse-agent-windows-" + windowsArch + ".exe" + files[name+".sig"] = "shared-windows-" + windowsArch + "-signature" + files[name+".sshsig"] = "shared-windows-" + windowsArch + "-ssh-signature" + } for _, helperTarget := range []string{"linux-amd64", "linux-arm64", "linux-armv7", "linux-armv6", "linux-386"} { name := "bin/pulse-agent-helper-" + helperTarget files[name] = "shared-helper-" + helperTarget @@ -576,11 +632,13 @@ func TestReleaseContainerContextTreatsServerSignaturesAsArchitectureBound(t *tes } } for _, windowsArch := range []string{"amd64", "arm64", "386"} { - name := "bin/pulse-agent-windows-" + windowsArch - target := "pulse-agent-windows-" + windowsArch + ".exe" - header := &tar.Header{Name: name, Mode: 0o777, Typeflag: tar.TypeSymlink, Linkname: target} - if err := tarWriter.WriteHeader(header); err != nil { - t.Fatalf("write %s alias to %s archive: %v", name, arch, err) + for _, suffix := range []string{"", ".sig", ".sshsig"} { + name := "bin/pulse-agent-windows-" + windowsArch + suffix + target := "pulse-agent-windows-" + windowsArch + ".exe" + suffix + header := &tar.Header{Name: name, Mode: 0o777, Typeflag: tar.TypeSymlink, Linkname: target} + if err := tarWriter.WriteHeader(header); err != nil { + t.Fatalf("write %s alias to %s archive: %v", name, arch, err) + } } } if err := tarWriter.Close(); err != nil { @@ -614,9 +672,11 @@ func TestReleaseContainerContextTreatsServerSignaturesAsArchitectureBound(t *tes t.Fatalf("prepared context retained duplicate universal payload: %v", err) } for _, windowsArch := range []string{"amd64", "arm64", "386"} { - aliasPath := filepath.Join(outputDir, "amd64", "bin", "pulse-agent-windows-"+windowsArch) - if _, err := os.Lstat(aliasPath); !os.IsNotExist(err) { - t.Fatalf("prepared context retained recreated Windows alias %s: %v", aliasPath, err) + for _, suffix := range []string{"", ".sig", ".sshsig"} { + aliasPath := filepath.Join(outputDir, "amd64", "bin", "pulse-agent-windows-"+windowsArch+suffix) + if _, err := os.Lstat(aliasPath); !os.IsNotExist(err) { + t.Fatalf("prepared context retained recreated Windows alias %s: %v", aliasPath, err) + } } } diff --git a/scripts/prepare-release-container-context.sh b/scripts/prepare-release-container-context.sh index 7dd67e7ad..4e7c83ddb 100755 --- a/scripts/prepare-release-container-context.sh +++ b/scripts/prepare-release-container-context.sh @@ -86,18 +86,20 @@ for arch in amd64 arm64; do validate_archive_entries "${archive}" tar --no-same-owner --no-same-permissions -xzf "${archive}" -C "${output_dir}/${arch}" for windows_arch in amd64 arm64 386; do - alias_path="${output_dir}/${arch}/bin/pulse-agent-windows-${windows_arch}" - expected_target="pulse-agent-windows-${windows_arch}.exe" - if [[ ! -L "${alias_path}" ]] || \ - [[ "$(readlink "${alias_path}")" != "${expected_target}" ]] || \ - [[ ! -f "${output_dir}/${arch}/bin/${expected_target}" ]]; then - echo "Error: ${archive} has an invalid Windows agent alias ${alias_path}." >&2 - exit 1 - fi - # runtime_prebuilt recreates these aliases after copying the immutable - # .exe payloads. Keep the manifest-covered context symlink-free rather - # than weakening its canonical-file rule. - rm -f "${alias_path}" + for suffix in "" .sig .sshsig; do + alias_path="${output_dir}/${arch}/bin/pulse-agent-windows-${windows_arch}${suffix}" + expected_target="pulse-agent-windows-${windows_arch}.exe${suffix}" + if [[ ! -L "${alias_path}" ]] || \ + [[ "$(readlink "${alias_path}")" != "${expected_target}" ]] || \ + [[ ! -f "${output_dir}/${arch}/bin/${expected_target}" ]]; then + echo "Error: ${archive} has an invalid Windows agent alias ${alias_path}." >&2 + exit 1 + fi + # runtime_prebuilt recreates these aliases after copying the + # immutable .exe payload and sidecars. Keep the manifest-covered + # context symlink-free rather than weakening its canonical-file rule. + rm -f "${alias_path}" + done done for required in \ bin/pulse \ diff --git a/scripts/release_asset_common.sh b/scripts/release_asset_common.sh index e73356c12..f2877195f 100644 --- a/scripts/release_asset_common.sh +++ b/scripts/release_asset_common.sh @@ -124,6 +124,20 @@ pulse_release_sign_directory_assets() { done < <(find "${dir}" -maxdepth 1 -type f ! -name '*.sig' ! -name '*.sshsig' -print0) } +pulse_release_link_windows_agent_aliases() { + local dir="$1" + local target="" + local suffix="" + + for target in windows-amd64 windows-arm64 windows-386; do + for suffix in "" .sig .sshsig; do + ln -sf \ + "pulse-agent-${target}.exe${suffix}" \ + "${dir}/pulse-agent-${target}${suffix}" + done + done +} + pulse_release_stage_server_archive() { local archive_path="$1" local staging_dir="$2" @@ -171,13 +185,6 @@ pulse_release_stage_server_archive() { dest="${staging_dir}/bin/pulse-agent-runner-${target}" install -m 0755 "${src}" "${dest}" done - ( - cd "${staging_dir}/bin" - ln -sf pulse-agent-windows-amd64.exe pulse-agent-windows-amd64 - ln -sf pulse-agent-windows-arm64.exe pulse-agent-windows-arm64 - ln -sf pulse-agent-windows-386.exe pulse-agent-windows-386 - ) - install -m 0755 "${PULSE_SCRIPTS_DIR}/install-container-agent.sh" "${staging_dir}/scripts/install-container-agent.sh" install -m 0755 "${PULSE_SCRIPTS_DIR}/install-docker.sh" "${staging_dir}/scripts/install-docker.sh" install -m 0755 "${rendered_installers_dir}/install.sh" "${staging_dir}/scripts/install.sh" @@ -189,6 +196,7 @@ pulse_release_stage_server_archive() { pulse_release_sign_directory_assets "${staging_dir}/bin" pulse_release_sign_directory_assets "${staging_dir}/scripts" pulse_release_sign_file "${staging_dir}/VERSION" + pulse_release_link_windows_agent_aliases "${staging_dir}/bin" mkdir -p "$(dirname "${archive_path}")" ( diff --git a/scripts/validate-release.sh b/scripts/validate-release.sh index af5ddb28a..f95fb4f9e 100755 --- a/scripts/validate-release.sh +++ b/scripts/validate-release.sh @@ -254,7 +254,7 @@ if [ "$SKIP_DOCKER" = false ]; then # Validate all required binaries exist and are non-empty info "Checking downloadable binaries in /opt/pulse/bin/..." - docker run --rm --entrypoint /bin/sh "$IMAGE" -c 'set -euo pipefail; cd /opt/pulse/bin; required="pulse pulse-agent-linux-amd64 pulse-agent-linux-arm64 pulse-agent-linux-armv7 pulse-agent-linux-armv6 pulse-agent-linux-386 pulse-agent-helper-linux-amd64 pulse-agent-helper-linux-arm64 pulse-agent-helper-linux-armv7 pulse-agent-helper-linux-armv6 pulse-agent-helper-linux-386 pulse-agent-runner-linux-amd64 pulse-agent-runner-linux-arm64 pulse-agent-runner-linux-armv7 pulse-agent-runner-linux-armv6 pulse-agent-runner-linux-386 pulse-agent-darwin-amd64 pulse-agent-darwin-arm64 pulse-agent-windows-amd64.exe pulse-agent-windows-amd64 pulse-agent-windows-arm64.exe pulse-agent-windows-arm64 pulse-agent-windows-386.exe pulse-agent-windows-386 pulse-agent-freebsd-amd64 pulse-agent-freebsd-arm64"; for f in $required; do [ -e "$f" ] || { echo "missing binary $f" >&2; exit 1; }; [ -s "$f" ] || { echo "empty binary $f" >&2; exit 1; }; done; [ "$(readlink pulse-agent-windows-amd64)" = "pulse-agent-windows-amd64.exe" ] || { echo "unified agent windows amd64 symlink broken" >&2; exit 1; }; [ "$(readlink pulse-agent-windows-arm64)" = "pulse-agent-windows-arm64.exe" ] || { echo "unified agent windows arm64 symlink broken" >&2; exit 1; }; [ "$(readlink pulse-agent-windows-386)" = "pulse-agent-windows-386.exe" ] || { echo "unified agent windows 386 symlink broken" >&2; exit 1; }; echo "All binaries present"' || { error "Binary validation failed"; exit 1; } + docker run --rm --entrypoint /bin/sh "$IMAGE" -c 'set -euo pipefail; cd /opt/pulse/bin; required="pulse pulse-agent-linux-amd64 pulse-agent-linux-arm64 pulse-agent-linux-armv7 pulse-agent-linux-armv6 pulse-agent-linux-386 pulse-agent-helper-linux-amd64 pulse-agent-helper-linux-arm64 pulse-agent-helper-linux-armv7 pulse-agent-helper-linux-armv6 pulse-agent-helper-linux-386 pulse-agent-runner-linux-amd64 pulse-agent-runner-linux-arm64 pulse-agent-runner-linux-armv7 pulse-agent-runner-linux-armv6 pulse-agent-runner-linux-386 pulse-agent-darwin-amd64 pulse-agent-darwin-arm64 pulse-agent-windows-amd64.exe pulse-agent-windows-amd64 pulse-agent-windows-arm64.exe pulse-agent-windows-arm64 pulse-agent-windows-386.exe pulse-agent-windows-386 pulse-agent-freebsd-amd64 pulse-agent-freebsd-arm64"; for f in $required; do [ -e "$f" ] || { echo "missing binary $f" >&2; exit 1; }; [ -s "$f" ] || { echo "empty binary $f" >&2; exit 1; }; done; for arch in amd64 arm64 386; do for suffix in "" .sig .sshsig; do alias="pulse-agent-windows-${arch}${suffix}"; target="pulse-agent-windows-${arch}.exe${suffix}"; [ -L "$alias" ] || { echo "missing Windows agent alias $alias" >&2; exit 1; }; [ "$(readlink "$alias")" = "$target" ] || { echo "Windows agent alias $alias does not target $target" >&2; exit 1; }; [ -s "$alias" ] || { echo "empty Windows agent alias $alias" >&2; exit 1; }; done; done; echo "All binaries present"' || { error "Binary validation failed"; exit 1; } success "All downloadable binaries present" # Validate the arch-resolved /usr/local/bin/pulse-agent symlink. The helm