diff --git a/e2e/local/docker-compose-agents.yml b/e2e/local/docker-compose-agents.yml
new file mode 100644
index 00000000..e8936134
--- /dev/null
+++ b/e2e/local/docker-compose-agents.yml
@@ -0,0 +1,24 @@
+services:
+ agent-linux-amd64:
+ image: jenkins/inbound-agent:latest
+ platform: linux/amd64
+ container_name: jenkins-agent-amd64
+ environment:
+ JENKINS_URL: http://host.docker.internal:8080
+ JENKINS_AGENT_NAME: agent-linux-amd64
+ JENKINS_SECRET: ${AGENT_AMD64_SECRET}
+ JENKINS_AGENT_WORKDIR: /home/jenkins/agent
+ extra_hosts:
+ - "host.docker.internal:host-gateway"
+
+ agent-linux-arm64:
+ image: jenkins/inbound-agent:latest
+ platform: linux/arm64
+ container_name: jenkins-agent-arm64
+ environment:
+ JENKINS_URL: http://host.docker.internal:8080
+ JENKINS_AGENT_NAME: agent-linux-arm64
+ JENKINS_SECRET: ${AGENT_ARM64_SECRET}
+ JENKINS_AGENT_WORKDIR: /home/jenkins/agent
+ extra_hosts:
+ - "host.docker.internal:host-gateway"
diff --git a/e2e/local/setup-agents.sh b/e2e/local/setup-agents.sh
new file mode 100755
index 00000000..3926bfb0
--- /dev/null
+++ b/e2e/local/setup-agents.sh
@@ -0,0 +1,261 @@
+#!/usr/bin/env bash
+# Usage: source e2e/setup-agents.sh
+#
+# Creates Jenkins JNLP agents for cross-platform testing, starts the Docker
+# containers, and waits for them to come online.
+#
+# Prerequisites:
+# - Jenkins running on localhost:8080 (admin/password)
+# - The jfrog plugin HPI installed in Jenkins
+# - Docker with multi-platform support (buildx)
+#
+# What it does:
+# 1. Enables the TCP slave agent listener (port 50000)
+# 2. Creates two permanent agents: agent-linux-amd64, agent-linux-arm64
+# 3. Configures the "releases-jfrog-cli" tool (Install from releases.jfrog.io)
+# 4. Starts Docker containers that connect back to Jenkins
+# 5. Creates a cross-platform test pipeline
+#
+# After running, trigger the test with:
+# curl -X POST http://localhost:8080/job/test-cross-platform/build -u admin:password
+
+set -euo pipefail
+
+JENKINS_URL="${JENKINS_URL:-http://localhost:8080}"
+JENKINS_USER="${JENKINS_USER:-admin}"
+JENKINS_PASS="${JENKINS_PASS:-password}"
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+
+get_crumb() {
+ local cookies="$1"
+ curl -sf -c "$cookies" "${JENKINS_URL}/crumbIssuer/api/json" \
+ -u "${JENKINS_USER}:${JENKINS_PASS}" | python3 -c "import json,sys; print(json.load(sys.stdin)['crumb'])"
+}
+
+run_groovy() {
+ local script="$1"
+ local cookies
+ cookies=$(mktemp)
+ local crumb
+ crumb=$(get_crumb "$cookies")
+ curl -sf -X POST "${JENKINS_URL}/scriptText" \
+ -u "${JENKINS_USER}:${JENKINS_PASS}" \
+ -b "$cookies" \
+ -H "Jenkins-Crumb: ${crumb}" \
+ --data-urlencode "script=${script}"
+ rm -f "$cookies"
+}
+
+echo "==> Checking Jenkins is up..."
+if ! curl -sf "${JENKINS_URL}/api/json" -u "${JENKINS_USER}:${JENKINS_PASS}" > /dev/null 2>&1; then
+ echo "ERROR: Jenkins not reachable at ${JENKINS_URL}"
+ return 1
+fi
+
+echo "==> Enabling TCP slave agent listener on port 50000..."
+run_groovy '
+import jenkins.model.Jenkins
+def jenkins = Jenkins.instance
+jenkins.setSlaveAgentPort(50000)
+jenkins.save()
+println "TCP listener enabled on port: " + jenkins.getSlaveAgentPort()
+'
+
+echo ""
+echo "==> Creating agents and retrieving secrets..."
+SECRETS=$(run_groovy '
+import jenkins.model.*
+import hudson.model.*
+import hudson.slaves.*
+
+def createAgent(String name, String label) {
+ def jenkins = Jenkins.instance
+ if (jenkins.getNode(name) != null) {
+ jenkins.removeNode(jenkins.getNode(name))
+ }
+ def launcher = new JNLPLauncher(true)
+ def agent = new DumbSlave(name, "/home/jenkins/agent", launcher)
+ agent.setLabelString(label)
+ agent.setRetentionStrategy(RetentionStrategy.INSTANCE)
+ jenkins.addNode(agent)
+}
+
+createAgent("agent-linux-amd64", "linux-amd64")
+createAgent("agent-linux-arm64", "linux-arm64")
+
+Jenkins.instance.nodes.each { node ->
+ if (node.name.startsWith("agent-linux")) {
+ def computer = node.toComputer()
+ if (computer instanceof hudson.slaves.SlaveComputer) {
+ println "SECRET_${node.name.replace("-", "_").toUpperCase()}=${computer.getJnlpMac()}"
+ }
+ }
+}
+')
+
+echo "$SECRETS"
+
+# Parse secrets
+export AGENT_AMD64_SECRET=$(echo "$SECRETS" | grep "SECRET_AGENT_LINUX_AMD64" | cut -d= -f2)
+export AGENT_ARM64_SECRET=$(echo "$SECRETS" | grep "SECRET_AGENT_LINUX_ARM64" | cut -d= -f2)
+
+if [ -z "$AGENT_AMD64_SECRET" ] || [ -z "$AGENT_ARM64_SECRET" ]; then
+ echo "ERROR: Failed to retrieve agent secrets"
+ return 1
+fi
+
+echo ""
+echo "==> Pulling agent images (both platforms)..."
+docker pull --platform linux/amd64 jenkins/inbound-agent:latest
+docker pull --platform linux/arm64 jenkins/inbound-agent:latest
+
+echo ""
+echo "==> Starting agent containers..."
+docker compose -f "$SCRIPT_DIR/docker-compose-agents.yml" up -d
+
+echo ""
+echo "==> Waiting for agents to come online..."
+for i in $(seq 1 30); do
+ ONLINE_COUNT=$(run_groovy '
+ def count = Jenkins.instance.nodes.count { node ->
+ node.name.startsWith("agent-linux") && node.toComputer()?.isOnline()
+ }
+ print count
+ ')
+ if [ "$ONLINE_COUNT" = "2" ]; then
+ echo "==> Both agents are online!"
+ break
+ fi
+ echo " ...waiting for agents ($ONLINE_COUNT/2 online, attempt $i/30)"
+ sleep 5
+done
+
+echo ""
+echo "==> Configuring JFrog CLI tool..."
+run_groovy '
+import jenkins.model.*
+import hudson.tools.*
+import io.jenkins.plugins.jfrog.*
+
+def jenkins = Jenkins.instance
+def desc = jenkins.getDescriptorByType(JfrogInstallation.DescriptorImpl.class)
+def existing = desc.getInstallations()?.toList() ?: []
+
+if (!existing.any { it.name == "releases-jfrog-cli" }) {
+ def installer = new ReleasesInstaller()
+ def props = new DescribableList(Saveable.NOOP)
+ props.add(new InstallSourceProperty([installer]))
+ def tool = new JfrogInstallation("releases-jfrog-cli", "", props)
+ existing.add(tool)
+ desc.setInstallations(existing.toArray(new JfrogInstallation[0]))
+ println "Tool releases-jfrog-cli configured"
+} else {
+ println "Tool releases-jfrog-cli already exists"
+}
+'
+
+echo ""
+echo "==> Creating cross-platform test pipeline..."
+COOKIES=$(mktemp)
+CRUMB=$(get_crumb "$COOKIES")
+
+# Delete if exists
+curl -sf -X POST "${JENKINS_URL}/job/test-cross-platform/doDelete" \
+ -u "${JENKINS_USER}:${JENKINS_PASS}" -b "$COOKIES" -H "Jenkins-Crumb: ${CRUMB}" 2>/dev/null || true
+
+CRUMB=$(get_crumb "$COOKIES")
+curl -sf -X POST "${JENKINS_URL}/createItem?name=test-cross-platform" \
+ -u "${JENKINS_USER}:${JENKINS_PASS}" -b "$COOKIES" -H "Jenkins-Crumb: ${CRUMB}" \
+ -H "Content-Type: application/xml" \
+ --data-binary @- << 'XMLEOF'
+
+
Once a tool is verified in a pipeline run, all subsequent stages in the same run + * skip the version check entirely — avoiding repeated HTTP HEAD requests to Artifactory. + * A new pipeline run produces a different run ID, so it gets its own verification. + * The agent OS is included in the key to distinguish same-path installations on + * agents with different architectures (e.g., linux-amd64 vs linux-arm64).
+ */ + private static final int MAX_CACHE_SIZE = 1000; + private static final Map+ * In Pipeline builds, the TaskListener wraps a FileLogStorage whose log file path + * contains the job name and build number (e.g., "jobs/my-job/builds/42/log"). + * This path is guaranteed unique per pipeline run. + *
+ * Falls back to null for non-Pipeline builds (Freestyle, etc.) where the listener + * structure is different — the caller treats null as "don't cache". + *
+ * Fragile: This relies on internal field names in workflow-api and workflow-support.
+ * Tested with Jenkins {@literal >=} 2.462.3 and workflow-cps. If a future Jenkins update
+ * renames these fields, the catch block returns null and caching degrades gracefully
+ * (repeated version checks, no failure). Re-verify after major Jenkins core upgrades.
+ */
+ private static String getCurrentRunId(TaskListener log) {
+ try {
+ // Reflection chain (workflow-api / workflow-support internals):
+ // CloseableTaskListener → mainDelegate (BufferedBuildListener) → out (IndexOutputStream) → this$0 (FileLogStorage) → log (File)
+ java.lang.reflect.Field mainField = log.getClass().getDeclaredField("mainDelegate");
+ mainField.setAccessible(true);
+ Object buildListener = mainField.get(log);
+
+ java.lang.reflect.Field outField = buildListener.getClass().getDeclaredField("out");
+ outField.setAccessible(true);
+ Object indexOut = outField.get(buildListener);
+
+ java.lang.reflect.Field storageField = indexOut.getClass().getDeclaredField("this$0");
+ storageField.setAccessible(true);
+ Object fileLogStorage = storageField.get(indexOut);
+
+ java.lang.reflect.Field logField = fileLogStorage.getClass().getDeclaredField("log");
+ logField.setAccessible(true);
+ File logFile = (File) logField.get(fileLogStorage);
+
+ return logFile.getPath();
+ } catch (Exception e) {
+ // Non-Pipeline build or different Jenkins version — fall back gracefully
+ LOGGER.fine("Could not determine pipeline run ID: " + e.getMessage());
+ return null;
+ }
+ }
+
+ private static void markVerified(String cacheKey, String currentRunId) {
+ if (currentRunId == null) {
+ return;
+ }
+ VERIFIED_IN_RUN.put(cacheKey, currentRunId);
+ }
+
/**
* Creates a unique lock key for the installation location.
* Version is excluded so all operations targeting the same binary path are serialized.
- *
- * @param toolLocation Installation directory
- * @param binaryName Binary file name
- * @return Unique lock key string
*/
private static String createLockKey(FilePath toolLocation, String binaryName) {
try {
@@ -267,17 +387,14 @@ public long[] invoke(File file, VirtualChannel channel) {
* @param log Task listener for logging
* @return true if CLI is the correct version, false otherwise
*/
- private static boolean isCorrectVersion(FilePath toolLocation, JFrogPlatformInstance instance,
- String repository, String version, String binaryName, TaskListener log) {
+ private static boolean isCorrectVersion(FilePath toolLocation, JFrogPlatformInstance instance,
+ String repository, String version, String binaryName,
+ String agentOsDetails, TaskListener log) {
try {
- // Use the same logic as shouldDownloadTool() from JFrogCliDownloader
- // but do it here to avoid unnecessary JFrogCliDownloader.invoke() calls
-
JenkinsProxyConfiguration proxyConfiguration = new JenkinsProxyConfiguration();
- // Use binaryName to construct the correct URL suffix (handles Windows jf.exe vs Unix jf)
- String cliUrlSuffix = String.format("/%s/v2-jf/%s/jfrog-cli-%s/%s", repository,
- StringUtils.defaultIfBlank(version, "[RELEASE]"),
- OsUtils.getOsDetails(), binaryName);
+ String cliUrlSuffix = String.format("/%s/v2-jf/%s/jfrog-cli-%s/%s", repository,
+ StringUtils.defaultIfBlank(version, "[RELEASE]"),
+ agentOsDetails, binaryName);
JenkinsBuildInfoLog buildInfoLog = new JenkinsBuildInfoLog(log);
String artifactoryUrl = instance.inferArtifactoryUrl();
@@ -295,6 +412,8 @@ private static boolean isCorrectVersion(FilePath toolLocation, JFrogPlatformInst
String expectedSha256 = getArtifactSha256(manager, cliUrlSuffix);
if (expectedSha256.isEmpty()) {
log.getLogger().println("[BinaryInstaller] WARNING: No SHA256 available from server — cannot verify version, assuming up-to-date (upgrade may be delayed)");
+ // Clean up stale 0-byte sha256 file left by older plugin versions
+ cleanupStaleSha256File(toolLocation, log);
return true;
}
@@ -319,6 +438,31 @@ public Boolean invoke(File f, VirtualChannel channel) throws IOException, Interr
}
}
+ /**
+ * Removes a stale 0-byte sha256 file left behind by older plugin versions.
+ * The file has no effect on current behaviour but can confuse operators inspecting
+ * the tool directory.
+ */
+ private static void cleanupStaleSha256File(FilePath toolLocation, TaskListener log) {
+ try {
+ toolLocation.act(new MasterToSlaveFileCallable