Repository: btraceio/btrace (2026-07-13T10:22:25Z)

Issue:
[3.0] Make Gradle fat-agent bundled probes execute end to end
## Context

The Gradle fat-agent plugin stages bundled probes under META-INF/btrace-probes/, but runtime loading does not currently consume that location. The documented feature can log that a probe is being loaded and silently do nothing.

## Scope

Choose and implement one canonical standalone bundled-probe layout. The planned layout is META-INF/btrace-probes/<binary-name>.class.

- Extend runtime lookup to consume the staged path.
- Validate names and reject traversal/arbitrary resource access.
- Make missing named probes a fatal build/startup error, not a silent no-op.
- Add a TestKit build plus launched-JVM integration test.
- Verify probe teardown.

## Likely files

- btrace-gradle-plugin/src/main/groovy/io/btrace/gradle/BTraceFatAgentPlugin.groovy
- btrace-agent/src/main/java/io/btrace/agent/Main.java
- btrace-gradle-plugin/src/test/java/io/btrace/gradle/BTraceFatAgentPluginTest.java
- Integration fixture

## Acceptance criteria

- [ ] A fat agent built by documented Gradle configuration executes its bundled probe.
- [ ] Nested package names work.
- [ ] Missing probes fail loudly.
- [ ] Invalid names cannot escape the intended resource namespace.
- [ ] The integration test observes expected output and clean teardown.


Discussion:

Patch:
diff --git a/btrace-agent/src/main/java/io/btrace/agent/Main.java b/btrace-agent/src/main/java/io/btrace/agent/Main.java
index 3dd4784a4..7e546bddc 100644
--- a/btrace-agent/src/main/java/io/btrace/agent/Main.java
+++ b/btrace-agent/src/main/java/io/btrace/agent/Main.java
@@ -116,6 +116,67 @@ public final class Main {
   public static final int BTRACE_DEFAULT_PORT = 2020;
   private static final boolean AGENT_DEBUG = Boolean.getBoolean("btrace.agent.debug");
   private static final Pattern KV_PATTERN = Pattern.compile(",");
+  private static final Pattern BUNDLED_PROBE_NAME =
+      Pattern.compile("[A-Za-z_$][A-Za-z0-9_$]*(\\.[A-Za-z_$][A-Za-z0-9_$]*)*");
+  private static final Set<String> JAVA_KEYWORDS =
+      Collections.unmodifiableSet(
+          new LinkedHashSet<>(
+              Arrays.asList(
+                  "_",
+                  "abstract",
+                  "assert",
+                  "boolean",
+                  "break",
+                  "byte",
+                  "case",
+                  "catch",
+                  "char",
+                  "class",
+                  "const",
+                  "continue",
+                  "default",
+                  "do",
+                  "double",
+                  "else",
+                  "enum",
+                  "extends",
+                  "false",
+                  "final",
+                  "finally",
+                  "float",
+                  "for",
+                  "goto",
+                  "if",
+                  "implements",
+                  "import",
+                  "instanceof",
+                  "int",
+                  "interface",
+                  "long",
+                  "native",
+                  "new",
+                  "null",
+                  "package",
+                  "private",
+                  "protected",
+                  "public",
+                  "return",
+                  "short",
+                  "static",
+                  "strictfp",
+                  "super",
+                  "switch",
+                  "synchronized",
+                  "this",
+                  "throw",
+                  "throws",
+                  "transient",
+                  "true",
+                  "try",
+                  "void",
+                  "volatile",
+                  "while")));
+  private static final String BUNDLED_PROBE_ROOT = "META-INF/btrace-probes/";
   private static final SharedSettings settings = SharedSettings.GLOBAL;
   private static final BTraceTransformer transformer =
       new BTraceTransformer(new DebugSupport(settings));
@@ -467,15 +528,17 @@ private static void initExtensions() {
       // Initialize invokedynamic bridge for extensions after loader is ready
       ExtensionBridgeImpl.initialize(extensionLoader);
 
-      // Load bundled probes from embedded extensions if configured
+      // Load standalone bundled probes or probes selected by embedded extension configurators.
       loadBundledProbes();
 
+    } catch (BundledProbeException e) {
+      throw e;
     } catch (Exception e) {
       log.error("Failed to initialize extension system: {}", e.getMessage(), e);
     }
   }
 
-  /** Load bundled probes from embedded extensions based on agent args or configurator. */
+  /** Load bundled probes from explicit agent arguments or embedded extension configurators. */
   private static void loadBundledProbes() {
     if (extensionLoader == null) {
       return;
@@ -501,7 +564,7 @@ private static void loadBundledProbes() {
       for (String p : probesArg.split(",")) {
         requested.add(p.trim());
       }
-      loadProbesFromNames(requested, toStdOut);
+      loadStandaloneProbes(requested, toStdOut);
     } else {
       // No explicit probes= — ask each extension's configurator.
       AgentRuntimeEnvironment env = new AgentRuntimeEnvironment();
@@ -524,7 +587,7 @@ private static void loadBundledProbes() {
         boolean toStdOut = config.getOutput() == ProbeConfiguration.Output.STDOUT;
         log.info(
             "Extension {} configurator enabled probes: {}", ext.getId(), config.getEnabledProbes());
-        loadProbesFromNames(config.getEnabledProbes(), toStdOut);
+        loadExtensionProbes(ext, config.getEnabledProbes(), toStdOut);
       }
     }
   }
@@ -557,32 +620,73 @@ private static ProbeConfiguration runConfigurator(
     }
   }
 
-  /** Load the named probes from embedded extensions. */
-  private static void loadProbesFromNames(List<String> probeNames, boolean traceToStdOut) {
-    for (ExtensionDescriptorDTO ext : extensionLoader.getAvailableExtensions()) {
-      if (!ext.isEmbedded()) {
-        continue;
+  /** Load standalone probes staged by the fat-agent Gradle plugin. */
+  static void loadStandaloneProbes(List<String> probeNames, boolean traceToStdOut) {
+    for (String probeName : probeNames) {
+      String resourcePath = bundledProbeResourcePath(probeName);
+      if (!loadEmbeddedProbe(resourcePath, probeName, traceToStdOut)) {
+        throw new BundledProbeException(
+            "Bundled probe '" + probeName + "' was not found or could not be loaded");
       }
-      List<String> bundledProbes = ext.getBundledProbes();
-      if (bundledProbes.isEmpty()) {
-        continue;
+      log.info("Loaded bundled probe: {}", probeName);
+    }
+  }
+
+  /** Load probes selected by one embedded extension's configurator. */
+  private static void loadExtensionProbes(
+      ExtensionDescriptorDTO ext, List<String> probeNames, boolean traceToStdOut) {
+    for (String probeName : probeNames) {
+      String resolvedProbeName = resolveExtensionProbeName(ext, probeName);
+      String path = bundledProbeResourcePath(resolvedProbeName);
+      if (!loadEmbeddedProbe(path, resolvedProbeName, traceToStdOut)) {
+        throw new BundledProbeException(
+            "Bundled probe '"
+                + probeName
+                + "' from extension '"
+                + ext.getId()
+                + "' was not found or could not be loaded");
       }
-      for (String probeName : probeNames) {
-        if (!probeName.matches("[A-Za-z0-9_.]+")) {
-          log.warn("Ignoring probe with invalid name: '{}'", probeName);
-          continue;
+      log.info("Loaded bundled probe: {} from extension {}", probeName, ext.getId());
+    }
+  }
+
+  static String resolveExtensionProbeName(ExtensionDescriptorDTO ext, String probeName) {
+    if (probeName != null && probeName.indexOf('.') == -1) {
+      for (String bundledProbe : ext.getBundledProbes()) {
+        int simpleNameStart = bundledProbe.lastIndexOf('.') + 1;
+        if (bundledProbe.regionMatches(simpleNameStart, probeName, 0, probeName.length())
+            && bundledProbe.length() - simpleNameStart == probeName.length()) {
+          return bundledProbe;
         }
-        for (String bundledProbe : bundledProbes) {
-          if (bundledProbe.endsWith("." + probeName) || bundledProbe.equals(probeName)) {
-            String path = ext.getResourceBasePath() + "/probes/" + probeName + ".class";
-            if (loadEmbeddedProbe(path, probeName, traceToStdOut)) {
-              log.info("Loaded bundled probe: {} from extension {}", probeName, ext.getId());
-              break;
-            }
-          }
+      }
+    }
+    return probeName;
+  }
+
+  static String bundledProbeResourcePath(String probeName) {
+    validateBundledProbeName(probeName);
+    return BUNDLED_PROBE_ROOT + probeName.replace('.', '/') + ".class";
+  }
+
+  static void validateBundledProbeName(String probeName) {
+    boolean valid = probeName != null && BUNDLED_PROBE_NAME.matcher(probeName).matches();
+    if (valid) {
+      for (String part : probeName.split("\\.")) {
+        if (JAVA_KEYWORDS.contains(part)) {
+          valid = false;
+          break;
         }
       }
     }
+    if (!valid) {
+      throw new BundledProbeException("Invalid bundled probe binary name: '" + probeName + "'");
+    }
+  }
+
+  static final class BundledProbeException extends IllegalStateException {
+    BundledProbeException(String message) {
+      super(message);
+    }
   }
 
   /** Load an embedded probe from classpath resources. */
diff --git a/btrace-gradle-plugin/src/main/groovy/io/btrace/gradle/BTraceFatAgentPlugin.groovy b/btrace-gradle-plugin/src/main/groovy/io/btrace/gradle/BTraceFatAgentPlugin.groovy
index 444d0b4d9..78bf3787d 100644
--- a/btrace-gradle-plugin/src/main/groovy/io/btrace/gradle/BTraceFatAgentPlugin.groovy
+++ b/btrace-gradle-plugin/src/main/groovy/io/btrace/gradle/BTraceFatAgentPlugin.groovy
@@ -4,8 +4,10 @@ import org.gradle.api.Plugin
 import org.gradle.api.Project
 import org.gradle.api.GradleException
 import org.gradle.api.tasks.Copy
+import org.gradle.api.tasks.PathSensitivity
 import org.gradle.api.tasks.bundling.Jar
 import org.gradle.api.file.DuplicatesStrategy
+import javax.lang.model.SourceVersion
 
 /**
  * Gradle plugin for building fat agent JARs with embedded extensions.
@@ -85,41 +87,87 @@ class BTraceFatAgentPlugin implements Plugin<Project> {
             group = 'BTrace Fat Agent'
             description = 'Stages bundled probes for fat agent JAR'
 
-            outputs.dir(new File(stagingDir, 'probes'))
+            inputs.files(project.provider { extension.probeBundle.probeDirs })
+                .withPropertyName('probeDirectories')
+                .withPathSensitivity(PathSensitivity.RELATIVE)
+            inputs.files(project.provider { extension.probeBundle.sourceRoots })
+                .withPropertyName('probeSourceRoots')
+                .withPathSensitivity(PathSensitivity.RELATIVE)
+            inputs.property(
+                'probeIncludes',
+                project.provider { new ArrayList<>(extension.probeBundle.includes) })
+            inputs.property(
+                'probeExcludes',
+                project.provider { new ArrayList<>(extension.probeBundle.excludes) })
+            outputs.dir(new File(stagingDir, 'META-INF/btrace-probes'))
 
             doLast {
                 def probeSpec = extension.probeBundle
+                def probesDir = new File(stagingDir, 'META-INF/btrace-probes')
+                project.delete(probesDir)
                 if (!probeSpec.hasProbes()) {
                     return
                 }
 
-                def probesDir = new File(stagingDir, 'META-INF/btrace-probes')
                 probesDir.mkdirs()
 
+                def toProbePath = { String binaryName ->
+                    validateProbeBinaryName(binaryName)
+                    return binaryName.replace('.', '/') + '.class'
+                }
+                def includePaths = probeSpec.includes.collect(toProbePath)
+                def excludePaths = probeSpec.excludes.collect(toProbePath)
+
                 // Copy pre-compiled probes
                 probeSpec.probeDirs.each { dir ->
-                    if (dir.exists()) {
-                        project.copy {
-                            from dir
-                            into probesDir
+                    if (!dir.isDirectory()) {
+                        throw new GradleException(
+                            "[fat-agent] Bundled probe directory does not exist: ${dir}")
+                    }
+                    project.copy {
+                        from dir
+                        into probesDir
+                        if (includePaths.isEmpty()) {
                             include '**/*.class'
-                            if (!probeSpec.includes.isEmpty()) {
-                                include probeSpec.includes.collect { "${it}.class" }
-                            }
-                            if (!probeSpec.excludes.isEmpty()) {
-                                exclude probeSpec.excludes.collect { "${it}.class" }
-                            }
+                        } else {
+                            include includePaths
+                        }
+                        if (!excludePaths.isEmpty()) {
+                            exclude excludePaths
                         }
-                        project.logger.info("[fat-agent] Staged probes from: ${dir}")
                     }
+                    project.logger.info("[fat-agent] Staged probes from: ${dir}")
                 }
 
                 // Compile source probes if configured
                 if (probeSpec.hasSourceProbes()) {
                     compileProbes(project, probeSpec, probesDir)
                 }
+
+                def stagedNames = [] as Set
+                probesDir.eachFileRecurse { File probeFile ->
+                    if (!probeFile.name.endsWith('.class')) return
+                    def relative = probesDir.toPath().relativize(probeFile.toPath()).toString()
+                    def binaryName = relative
+                        .replace(File.separatorChar, (char) '/')
+                        .replaceAll(/\.class$/, '')
+                        .replace('/', '.')
+                    validateProbeBinaryName(binaryName)
+                    stagedNames.add(binaryName)
+                }
+                probeSpec.includes.each { String binaryName ->
+                    if (!stagedNames.contains(binaryName)) {
+                        throw new GradleException(
+                            "[fat-agent] Named bundled probe '${binaryName}' was not found in configured probe inputs")
+                    }
+                }
+                if (stagedNames.isEmpty()) {
+                    throw new GradleException('[fat-agent] Bundled probe inputs produced no .class files')
+                }
+                project.logger.lifecycle("[fat-agent] Staged bundled probes: ${stagedNames.join(', ')}")
             }
         }
+        stageProbes.configure { mustRunAfter stageExtensions }
 
         // Task: Build fat agent JAR
         // Try to use ShadowJar if available for package relocation support
@@ -240,6 +288,20 @@ class BTraceFatAgentPlugin implements Plugin<Project> {
         return resolved
     }
 
+    private static void validateProbeBinaryName(String binaryName) {
+        boolean valid = binaryName != null &&
+            binaryName ==~ /[A-Za-z_$][A-Za-z0-9_$]*(\.[A-Za-z_$][A-Za-z0-9_$]*)*/
+        if (valid) {
+            valid = binaryName.split(/\./).every { String part ->
+                SourceVersion.isIdentifier(part) && !SourceVersion.isKeyword(part)
+            }
+        }
+        if (!valid) {
+            throw new GradleException(
+                "[fat-agent] Invalid bundled probe binary name: '${binaryName}'")
+        }
+    }
+
     /**
      * Stage a resolved extension to the staging directory.
      */
diff --git a/docs/BTraceExtensionDevelopmentGuide.md b/docs/BTraceExtensionDevelopmentGuide.md
index 0f51a2d0d..b67f132e0 100644
--- a/docs/BTraceExtensionDevelopmentGuide.md
+++ b/docs/BTraceExtensionDevelopmentGuide.md
@@ -399,18 +399,24 @@ There are two activation modes:
 
 ### Declaring Bundled Probes
 
-List probe class simple names in `extension.properties`:
+Declare the compiled probe directory and exact binary names in the fat-agent build:
 
-```properties
-probes=SparkJobTracer,SparkStageTracer,SparkExecutorTracer
+```groovy
+btraceFatAgent {
+    bundledProbes {
+        from layout.buildDirectory.dir('compiled-probes').get().asFile
+        include 'org.example.spark.SparkJobTracer'
+        include 'org.example.spark.SparkStageTracer'
+    }
+}
 ```
 
-The corresponding `.class` files must be present in the fat agent JAR under:
+The corresponding `.class` files are stored in the fat agent JAR under:
 ```
-META-INF/btrace-probes/{probe-simple-name}.class
+META-INF/btrace-probes/{binary-name-as-package-path}.class
 ```
 
-The fat agent plugin stages them automatically when you use `bundledProbes {}` in `btraceFatAgent`.
+Both explicit `probes=` selection and configurator selection resolve this same canonical location.
 
 ### Implementing a Configurator
 
diff --git a/docs/tutorials/08-fat-agent.md b/docs/tutorials/08-fat-agent.md
index 84acbcd4b..866220785 100644
--- a/docs/tutorials/08-fat-agent.md
+++ b/docs/tutorials/08-fat-agent.md
@@ -209,35 +209,32 @@ processOrder  p50=58ms  p95=346ms  p99=402ms  (n=41)
 > permission, it is active for anyone who runs your jar, with no separate opt-in — treat the act of
 > embedding itself as the grant.
 
-## Step 5 — Heads up: "zero-config startup probes" don't work yet in this checkout
-
-`docs/BTraceExtensionDevelopmentGuide.md` documents a second mechanism: bundle a *compiled probe*
-class into the fat agent (via `btraceFatAgent { bundledProbes { from(...) } }`) so it can run
-automatically at JVM startup — either because you pass `-javaagent:jar=probes=MyProbe` explicitly,
-or because an extension's `ExtensionConfigurator` picks it for you. That's a genuinely useful
-capability for a platform engineer (a Spark executor that self-instruments with no operator
-action), so it's worth knowing precisely where it stands.
-
-Independently tracing the two ends of this feature turned up a mismatch:
-
-- The Gradle plugin's `stageProbes` task copies compiled probe classes to
-  `META-INF/btrace-probes/` inside the jar (`BTraceFatAgentPlugin.groovy`), matching what
-  `docs/architecture/fat-agent-plugin.md` and `docs/BTraceExtensionDevelopmentGuide.md` both
-  describe.
-- The agent's actual loader never reads from that path. `Main.loadProbesFromNames()` only looks at
-  each *embedded extension's own* `getBundledProbes()` list (populated from a `probes=` key inside
-  that extension's own `extension.properties`), and then loads the named class from
-  `<extension's resourceBasePath>/probes/<Name>.class` — i.e.
-  `META-INF/btrace-extensions/<extension-id>/probes/<Name>.class`
-  (`EmbeddedExtensionRepository.discoverBundledProbes()` /
-  `Main.loadEmbeddedProbe()`). Nothing reads `META-INF/btrace-probes/` at runtime.
-
-So as of this checkout, staging a probe with the fat-agent plugin's `bundledProbes {}` block and
-then passing `probes=YourProbe` (or relying on a configurator) is a silent no-op: the agent logs
-`"Loading bundled probes: YourProbe"` (a real, verified log line from `Main.java`), scans embedded
-extensions for one whose bundled-probes list contains that name, finds none, and moves on — no
-error, and no probe. Until that's wired up, use the attach-based flow from Step 4
-(`btrace <PID> Script.java`) for anything you'd otherwise want auto-started.
+## Step 5 — Add a startup probe
+
+The Gradle plugin can bundle a compiled probe so it starts with the target JVM. Configure the
+directory containing the compiled package tree and name the probe by its full binary name:
+
+```groovy
+btraceFatAgent {
+    bundledProbes {
+        from layout.buildDirectory.dir('compiled-probes').get().asFile
+        include 'com.example.OrderStartupProbe'
+    }
+}
+```
+
+The class is stored at
+`META-INF/btrace-probes/com/example/OrderStartupProbe.class`. Select it with:
+
+```sh
+java -javaagent:build/libs/btrace-agent-fat.jar=probes=com.example.OrderStartupProbe,output=stdout \
+     DemoApp
+```
+
+Probe names are exact Java binary names. Invalid names, configured classes that are missing at
+build time, and requested resources that are missing at startup all fail loudly instead of being
+ignored. This also prevents a `probes=` value from escaping the `META-INF/btrace-probes/`
+namespace.
 
 ## Step 6 — Maven status in 3.0.0
 


Test patch:
diff --git a/btrace-agent/src/test/java/io/btrace/agent/BundledProbeResourceTest.java b/btrace-agent/src/test/java/io/btrace/agent/BundledProbeResourceTest.java
new file mode 100644
index 000000000..32fac02d8
--- /dev/null
+++ b/btrace-agent/src/test/java/io/btrace/agent/BundledProbeResourceTest.java
@@ -0,0 +1,101 @@
+/*
+ * Copyright (c) 2008, 2024, Jaroslav Bachorik <j.bachorik@btrace.io>.
+ * All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.btrace.agent;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import io.btrace.extension.ExtensionDescriptorDTO;
+import java.nio.file.Paths;
+import java.util.Arrays;
+import java.util.Collections;
+import org.junit.jupiter.api.Test;
+
+class BundledProbeResourceTest {
+  @Test
+  void nestedBinaryNameMapsInsideCanonicalResourceRoot() {
+    assertEquals(
+        "META-INF/btrace-probes/com/example/NestedProbe.class",
+        Main.bundledProbeResourcePath("com.example.NestedProbe"));
+  }
+
+  @Test
+  void innerClassBinaryNameIsAllowed() {
+    assertEquals(
+        "META-INF/btrace-probes/com/example/Probe$Nested.class",
+        Main.bundledProbeResourcePath("com.example.Probe$Nested"));
+  }
+
+  @Test
+  void extensionProbeSimpleNameResolvesToDeclaredBinaryName() {
+    ExtensionDescriptorDTO extension =
+        new ExtensionDescriptorDTO.Builder()
+            .id("spark")
+            .version("1.0")
+            .jarPath(Paths.get("spark.jar"))
+            .bundledProbes(
+                Arrays.asList("org.example.spark.SparkJobTracer", "org.example.OtherProbe"))
+            .build();
+
+    String resolved = Main.resolveExtensionProbeName(extension, "SparkJobTracer");
+
+    assertEquals("org.example.spark.SparkJobTracer", resolved);
+    assertEquals(
+        "META-INF/btrace-probes/org/example/spark/SparkJobTracer.class",
+        Main.bundledProbeResourcePath(resolved));
+  }
+
+  @Test
+  void extensionProbeQualifiedNameIsPreserved() {
+    ExtensionDescriptorDTO extension =
+        new ExtensionDescriptorDTO.Builder()
+            .id("spark")
+            .version("1.0")
+            .jarPath(Paths.get("spark.jar"))
+            .bundledProbes(Collections.singletonList("org.example.spark.SparkJobTracer"))
+            .build();
+
+    assertEquals(
+        "org.example.spark.SparkJobTracer",
+        Main.resolveExtensionProbeName(extension, "org.example.spark.SparkJobTracer"));
+  }
+
+  @Test
+  void traversalAndResourceSyntaxAreRejected() {
+    String[] invalid = {
+      "../Probe",
+      "com/example/Probe",
+      "com..example.Probe",
+      ".Probe",
+      "Probe.class",
+      "class.Probe",
+      "Probe.null",
+      ""
+    };
+    for (String name : invalid) {
+      assertThrows(Main.BundledProbeException.class, () -> Main.bundledProbeResourcePath(name));
+    }
+    assertThrows(Main.BundledProbeException.class, () -> Main.bundledProbeResourcePath(null));
+  }
+
+  @Test
+  void missingNamedProbeFailsLoudly() {
+    assertThrows(
+        Main.BundledProbeException.class,
+        () -> Main.loadStandaloneProbes(Collections.singletonList("missing.NoSuchProbe"), false));
+  }
+}
diff --git a/btrace-gradle-plugin/src/test/java/io/btrace/gradle/BTraceFatAgentPluginTest.java b/btrace-gradle-plugin/src/test/java/io/btrace/gradle/BTraceFatAgentPluginTest.java
index 8723633eb..50fcaf831 100644
--- a/btrace-gradle-plugin/src/test/java/io/btrace/gradle/BTraceFatAgentPluginTest.java
+++ b/btrace-gradle-plugin/src/test/java/io/btrace/gradle/BTraceFatAgentPluginTest.java
@@ -19,16 +19,18 @@
 package io.btrace.gradle;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
 import java.io.File;
 import java.io.FileWriter;
 import java.io.IOException;
+import java.io.InputStream;
 import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.nio.file.Path;
+import java.util.concurrent.TimeUnit;
+import java.util.jar.JarFile;
 import org.gradle.testkit.runner.BuildResult;
 import org.gradle.testkit.runner.GradleRunner;
 import org.gradle.testkit.runner.TaskOutcome;
@@ -223,6 +225,213 @@ void bundledProbesDslAvailable() throws IOException {
         assertTrue(result.getOutput().contains("HAS_PROBES=true"));
     }
 
+    @Test
+    @DisplayName("Fat agent packages nested bundled probe binary names")
+    void packagesNestedBundledProbe() throws IOException {
+        Path probe = projectDir.resolve("probes/com/example/NestedProbe.class");
+        Files.createDirectories(probe.getParent());
+        Files.write(probe, new byte[] {0, 1, 2, 3});
+        writeFile(
+                buildFile,
+                "plugins { id 'io.btrace.fat-agent' }\n"
+                        + "btraceFatAgent {\n"
+                        + "  bundledProbes {\n"
+                        + "    from 'probes'\n"
+                        + "    include 'com.example.NestedProbe'\n"
+                        + "  }\n"
+                        + "}\n");
+
+        BuildResult result = createRunner().withArguments("fatAgentJar").build();
+
+        assertEquals(TaskOutcome.SUCCESS, result.task(":fatAgentJar").getOutcome());
+        try (JarFile jar =
+                new JarFile(projectDir.resolve("build/libs/btrace-agent-fat.jar").toFile())) {
+            assertNotNull(jar.getEntry("META-INF/btrace-probes/com/example/NestedProbe.class"));
+        }
+    }
+
+    @Test
+    @DisplayName("stageProbes reruns when a bundled probe class changes")
+    void stageProbesTracksProbeDirectoryContents() throws IOException {
+        Path probe = projectDir.resolve("probes/com/example/NestedProbe.class");
+        Files.createDirectories(probe.getParent());
+        Files.write(probe, new byte[] {0, 1, 2, 3});
+        writeFile(
+                buildFile,
+                "plugins { id 'io.btrace.fat-agent' }\n"
+                        + "btraceFatAgent {\n"
+                        + "  bundledProbes { from 'probes' }\n"
+                        + "}\n");
+
+        BuildResult first = createRunner().withArguments("stageProbes").build();
+        BuildResult unchanged = createRunner().withArguments("stageProbes").build();
+        Files.write(probe, new byte[] {4, 5, 6, 7});
+        BuildResult changed = createRunner().withArguments("stageProbes").build();
+
+        assertEquals(TaskOutcome.SUCCESS, first.task(":stageProbes").getOutcome());
+        assertEquals(TaskOutcome.UP_TO_DATE, unchanged.task(":stageProbes").getOutcome());
+        assertEquals(TaskOutcome.SUCCESS, changed.task(":stageProbes").getOutcome());
+        assertEquals(
+                (byte) 4,
+                Files.readAllBytes(
+                                projectDir.resolve(
+                                        "build/fat-agent-staging/META-INF/btrace-probes/com/example/NestedProbe.class"))
+                        [0]);
+    }
+
+    @Test
+    @DisplayName("stageProbes reruns when bundled probe selection changes")
+    void stageProbesTracksProbeSelection() throws IOException {
+        Path firstProbe = projectDir.resolve("probes/com/example/FirstProbe.class");
+        Path secondProbe = projectDir.resolve("probes/com/example/SecondProbe.class");
+        Files.createDirectories(firstProbe.getParent());
+        Files.write(firstProbe, new byte[] {0});
+        Files.write(secondProbe, new byte[] {1});
+        writeProbeSelection("com.example.FirstProbe");
+
+        BuildResult first = createRunner().withArguments("stageProbes").build();
+        writeProbeSelection("com.example.SecondProbe");
+        BuildResult changed = createRunner().withArguments("stageProbes").build();
+
+        assertEquals(TaskOutcome.SUCCESS, first.task(":stageProbes").getOutcome());
+        assertEquals(TaskOutcome.SUCCESS, changed.task(":stageProbes").getOutcome());
+        Path stagedRoot = projectDir.resolve("build/fat-agent-staging/META-INF/btrace-probes");
+        assertTrue(Files.notExists(stagedRoot.resolve("com/example/FirstProbe.class")));
+        assertTrue(Files.exists(stagedRoot.resolve("com/example/SecondProbe.class")));
+    }
+
+    @Test
+    @DisplayName("Launched fat agent executes and tears down a nested bundled probe")
+    void launchedFatAgentExecutesAndTearsDownBundledProbe() throws Exception {
+        Path agentSource = projectDir.resolve("src/main/java/io/btrace/agent/Main.java");
+        Path appSource = projectDir.resolve("src/main/java/demo/App.java");
+        Path probeSource = projectDir.resolve("probe-src/com/example/NestedProbe.java");
+        Files.createDirectories(agentSource.getParent());
+        Files.createDirectories(appSource.getParent());
+        Files.createDirectories(probeSource.getParent());
+        Files.writeString(
+                agentSource,
+                "package io.btrace.agent;\n"
+                        + "import java.io.InputStream;\n"
+                        + "public final class Main {\n"
+                        + "  public static void premain(String name) throws Exception {\n"
+                        + "    String path = \"META-INF/btrace-probes/\" + name.replace('.', '/') + \".class\";\n"
+                        + "    ClassLoader loader = Main.class.getClassLoader();\n"
+                        + "    try (InputStream in = loader != null ? loader.getResourceAsStream(path) : ClassLoader.getSystemResourceAsStream(path)) {\n"
+                        + "      if (in == null) throw new IllegalStateException(\"missing \" + path);\n"
+                        + "      Class<?> probe = new ProbeLoader().define(in.readAllBytes());\n"
+                        + "      probe.getMethod(\"start\").invoke(null);\n"
+                        + "      Runtime.getRuntime().addShutdownHook(new Thread(() -> {\n"
+                        + "        try { probe.getMethod(\"stop\").invoke(null); }\n"
+                        + "        catch (Exception e) { throw new RuntimeException(e); }\n"
+                        + "      }));\n"
+                        + "    }\n"
+                        + "  }\n"
+                        + "  private static final class ProbeLoader extends ClassLoader {\n"
+                        + "    Class<?> define(byte[] bytes) { return defineClass(null, bytes, 0, bytes.length); }\n"
+                        + "  }\n"
+                        + "}\n",
+                StandardCharsets.UTF_8);
+        Files.writeString(
+                appSource,
+                "package demo;\n"
+                        + "public final class App {\n"
+                        + "  public static void main(String[] args) { System.out.println(\"APP_RAN\"); }\n"
+                        + "}\n",
+                StandardCharsets.UTF_8);
+        Files.writeString(
+                probeSource,
+                "package com.example;\n"
+                        + "public final class NestedProbe {\n"
+                        + "  public static void start() { System.out.println(\"BUNDLED_PROBE_EXECUTED\"); }\n"
+                        + "  public static void stop() { System.out.println(\"BUNDLED_PROBE_TORN_DOWN\"); }\n"
+                        + "}\n",
+                StandardCharsets.UTF_8);
+        writeFile(
+                buildFile,
+                "plugins { id 'io.btrace.fat-agent' }\n"
+                        + "tasks.register('compileBundledProbe', JavaCompile) {\n"
+                        + "  source = fileTree('probe-src') { include '**/*.java' }\n"
+                        + "  classpath = files()\n"
+                        + "  destinationDirectory = layout.buildDirectory.dir('compiled-probes')\n"
+                        + "  options.release = 11\n"
+                        + "}\n"
+                        + "tasks.named('stageProbes') { dependsOn 'compileBundledProbe' }\n"
+                        + "btraceFatAgent {\n"
+                        + "  agentJarTask = 'jar'\n"
+                        + "  manifestAttributes['Premain-Class'] = 'io.btrace.agent.Main'\n"
+                        + "  manifestAttributes['Agent-Class'] = 'io.btrace.agent.Main'\n"
+                        + "  bundledProbes {\n"
+                        + "    from layout.buildDirectory.dir('compiled-probes').get().asFile\n"
+                        + "    include 'com.example.NestedProbe'\n"
+                        + "  }\n"
+                        + "}\n");
+
+        createRunner().withArguments("fatAgentJar", "classes").build();
+
+        String java = Path.of(System.getProperty("java.home"), "bin", "java").toString();
+        Process process =
+                new ProcessBuilder(
+                                java,
+                                "-javaagent:"
+                                        + projectDir.resolve(
+                                                "build/libs/btrace-agent-fat.jar")
+                                        + "=com.example.NestedProbe",
+                                "-cp",
+                                projectDir.resolve("build/classes/java/main").toString(),
+                                "demo.App")
+                        .redirectErrorStream(true)
+                        .start();
+        assertTrue(process.waitFor(20, TimeUnit.SECONDS), "launched JVM did not terminate");
+        String output;
+        try (InputStream input = process.getInputStream()) {
+            output = new String(input.readAllBytes(), StandardCharsets.UTF_8);
+        }
+        assertEquals(0, process.exitValue(), output);
+        assertTrue(output.contains("BUNDLED_PROBE_EXECUTED"), output);
+        assertTrue(output.contains("APP_RAN"), output);
+        assertTrue(output.contains("BUNDLED_PROBE_TORN_DOWN"), output);
+    }
+
+    @Test
+    @DisplayName("Named bundled probes must exist")
+    void missingNamedBundledProbeFailsBuild() throws IOException {
+        Files.createDirectories(projectDir.resolve("probes"));
+        writeFile(
+                buildFile,
+                "plugins { id 'io.btrace.fat-agent' }\n"
+                        + "btraceFatAgent {\n"
+                        + "  bundledProbes {\n"
+                        + "    from 'probes'\n"
+                        + "    include 'com.example.MissingProbe'\n"
+                        + "  }\n"
+                        + "}\n");
+
+        BuildResult result = createRunner().withArguments("stageProbes").buildAndFail();
+
+        assertTrue(result.getOutput().contains("Named bundled probe 'com.example.MissingProbe'"));
+        assertTrue(result.getOutput().contains("was not found"));
+    }
+
+    @Test
+    @DisplayName("Bundled probe names cannot escape the resource namespace")
+    void invalidBundledProbeNameFailsBuild() throws IOException {
+        Files.createDirectories(projectDir.resolve("probes"));
+        writeFile(
+                buildFile,
+                "plugins { id 'io.btrace.fat-agent' }\n"
+                        + "btraceFatAgent {\n"
+                        + "  bundledProbes {\n"
+                        + "    from 'probes'\n"
+                        + "    include '../outside'\n"
+                        + "  }\n"
+                        + "}\n");
+
+        BuildResult result = createRunner().withArguments("stageProbes").buildAndFail();
+
+        assertTrue(result.getOutput().contains("Invalid bundled probe binary name: '../outside'"));
+    }
+
     @Test
     @DisplayName("Auto-discover can be disabled")
     void autoDiscoverConfigurable() throws IOException {
@@ -333,6 +542,20 @@ private GradleRunner createRunner() {
             .forwardOutput();
     }
 
+    private void writeProbeSelection(String probeName) throws IOException {
+        writeFile(
+                buildFile,
+                "plugins { id 'io.btrace.fat-agent' }\n"
+                        + "btraceFatAgent {\n"
+                        + "  bundledProbes {\n"
+                        + "    from 'probes'\n"
+                        + "    include '"
+                        + probeName
+                        + "'\n"
+                        + "  }\n"
+                        + "}\n");
+    }
+
     private void writeFile(File file, String content) throws IOException {
         try (FileWriter writer = new FileWriter(file)) {
             writer.write(content);