Agent attach (experimental)¶
Spectre's :agent module lets you attach to a running, Spectre-instrumented JVM and
drive its UI from a separate process — no need to mount routes at startup, no HTTP, no
network listener.
This is the right transport when:
- Your test JVM and the UI JVM are different processes by design, but you don't want to modify the UI app's startup wiring.
- You want to inspect a long-running Spectre-aware app interactively (the future
spectre attach <pid>CLI builds on this surface). - You're driving an IntelliJ-hosted Compose surface from a sister process. Note: see the current limitations below — IntelliJ support is gated until further validation.
For comparison with the other transports, see Cross-JVM access (HTTP) and
IntelliJ-hosted Compose (in-process via intellij-ide-starter).
Experimental API
Everything under dev.sebastiano.spectre.agent.* is annotated
@ExperimentalSpectreAgentApi and requires explicit opt-in. The API may change in any
release until the UX stabilizes. See Stability policy.
Trust boundary
The agent transport is local only and intended for trusted dev/test environments. Trust model:
- Communication is over a Unix Domain Socket under a short private directory in
/tmp/. Filesystem permissions (directory mode 0700, socket mode 0600) are the only access control. - The attaching JVM must run as the same OS user as the target JVM.
- There is no authentication and no encryption on the wire.
- The published
spectre-agentAPI jar is for the attaching JVM. Thespectre-agent-runtimejar gets loaded into the target JVM. See Artifact roles below.
See Security notes for the full risk register.
Requirements¶
- JDK 21+ on both the attaching and target JVMs.
- The attaching JVM must be a JDK (not a JRE) with the
jdk.attachmodule on the module graph. - The target JVM must include Spectre
:coreon its classpath. The agent does not inject Spectre into the target; it reflectively bootstraps off the:corethat's already loaded. The agent JAR itself is supplied by the attaching JVM at attach time. - macOS and Linux only in the current preview. Windows support is tracked as a follow-up (named pipes via JNA or junixsocket).
- The target JVM should be started with
-XX:+EnableDynamicAgentLoading. Without it, attach prints a stderr warning per JEP 451 and a future JDK will reject the attach entirely.
Artifact roles¶
Agent attach involves two JVMs:
- Target JVM — the Compose app you want to inspect or drive.
- Attacher JVM — the test, inspector, or tool process that calls
AgentAttach.attach(pid).
The target JVM must already have Spectre :core on its classpath. The agent runtime does
not inject :core; it reflectively locates ComposeAutomator in the target's classloader
after it has been loaded into that JVM.
// build.gradle.kts of the target application
dependencies {
implementation("dev.sebastiano.spectre:spectre-core:<version>")
// No `spectre-agent` or `spectre-agent-runtime` dependency is needed in the target.
// The attacher supplies the runtime jar to the JDK Attach API.
}
The attacher JVM usually needs two artifacts:
spectre-agent— the normal API jar that your test/inspector code compiles against.spectre-agent-runtime— the loadable Java-agent runtime jar that gets passed toVirtualMachine.loadAgent(...).
The easiest Gradle shape is a normal implementation dependency plus a runtime-only dependency on the loadable runtime artifact:
dependencies {
implementation("dev.sebastiano.spectre:spectre-agent:<version>")
runtimeOnly("dev.sebastiano.spectre:spectre-agent-runtime:<version>")
}
AgentAttach resolves the loadable runtime jar in this order:
AttachOptions.agentJarPath-Ddev.sebastiano.spectre.agent.runtimeJar=<path>- Classpath auto-discovery of a physical
spectre-agent-runtime-<version>.jar - The in-repo fallback at
<cwd>/agent-runtime/build/libs/agent-runtime-*.jar
In normal Gradle usage, runtimeOnly(...) makes Gradle launch the attacher with the runtime jar
listed in java.class.path; Spectre scans that classpath, takes the physical jar path, and passes
that path to VirtualMachine.loadAgent(...). The attacher does not call classes from the runtime
jar directly, and the target still does not need spectre-agent-runtime declared as a dependency.
How attach works¶
AgentAttach.attach(pid) performs this sequence:
- Resolve the loadable
spectre-agent-runtime-<version>.jar. - Create a fresh Unix Domain Socket path such as
/tmp/sp-a-<pid>-<8char-uuid>/agent.sock. - Run attach preflights, including the same-OS-user check.
- Call
VirtualMachine.attach(pid).loadAgent(runtimeJarPath, udsPath). - The target JVM loads the runtime jar and invokes
SpectreAgent.agentmain(...). - Inside the target JVM,
SpectreAgentfindsComposeAutomatorfrom the target's existing:coredependency, creates an in-process automator, and starts an IPC server on the UDS path. - The attacher connects an
IpcClientto that socket and returnsAttachedAutomator.
After that, calls such as windows(), findByTestTag(...), click(...), and screenshot() are
small CBOR requests over the socket. They execute inside the target JVM against the in-process
automator, then return DTOs or bytes to the attacher.
Custom runtime jar path¶
Classpath auto-discovery is the default for normal Gradle runs, but AttachOptions.agentJarPath
and -Ddev.sebastiano.spectre.agent.runtimeJar=<path> are explicit overrides and win before the
classpath scan. Use them for custom launchers, shaded tools, module-path launches, and ad-hoc
scripts that hide the physical runtime jar from java.class.path.
import dev.sebastiano.spectre.agent.AgentAttach
import dev.sebastiano.spectre.agent.AttachOptions
import java.nio.file.Path
AgentAttach.attach(
pid = targetPid,
options =
AttachOptions(
agentJarPath = Path.of("/abs/path/to/spectre-agent-runtime-<version>.jar"),
),
)
Equivalent: set -Ddev.sebastiano.spectre.agent.runtimeJar=<path> on the attacher's JVM.
When working inside the Spectre repo, AgentAttach also falls back to
<cwd>/agent-runtime/build/libs/agent-runtime-*.jar so local manual recipes keep working after
./gradlew :agent-runtime:jar.
Consumers that cannot use the published Maven coordinate still have two supported paths:
-
As a project dependency (you're inside the Spectre repo or a Gradle composite build that includes it):
-
As an explicit path via
AttachOptions.agentJarPathordev.sebastiano.spectre.agent.runtimeJar, as shown above.
Start the target with the dynamic-agent flag (suppresses the JEP 451 stderr warning):
Attaching¶
In the attaching JVM (typically a test process), opt in to the experimental API and use
AgentAttach.attach:
@file:OptIn(ExperimentalSpectreAgentApi::class)
import dev.sebastiano.spectre.agent.AgentAttach
import dev.sebastiano.spectre.agent.AttachOptions
import dev.sebastiano.spectre.agent.ExperimentalSpectreAgentApi
import dev.sebastiano.spectre.agent.SpectreProcesses
// Find the target by name.
val target = SpectreProcesses.findByName("MyApp").single()
AgentAttach.attach(target.pid).use { automator ->
val windows = automator.windows()
val submitNodes = automator.findByTestTag("Submit")
if (submitNodes.isNotEmpty()) {
automator.click(submitNodes.first().key)
}
val pngBytes = automator.screenshot()
} // detach + cleanup on close()
AttachedAutomator is AutoCloseable. Closing it sends an AgentRequest.Detach over the
wire; the agent stops accepting new requests, releases its ComposeAutomator, unlinks
the UDS path, and removes its shutdown hook. A target-side shutdown hook covers crash
cleanup.
AttachOptions¶
AttachOptions(
agentJarPath = null, // null = auto-locate (see "Artifact roles" above)
udsPath = null, // null = /tmp/sp-a-<pid>-<8char-uuid>/agent.sock
attachTimeoutMs = 5_000, // how long to wait for the agent's IPC server to come up
)
If you override udsPath with a path under an existing directory, you own that parent
directory's permissions. Spectre creates the default per-attach directory as mode 0700 and the
socket as mode 0600, but it does not chmod directories it did not create.
AgentAttach.attach runs a same-user preflight via ProcessHandle and throws
AttachPermissionDeniedException if the target JVM is owned by a different OS user (the
JDK Attach API only works across attach-compatible same-user processes on POSIX).
The JEP 451 -XX:+EnableDynamicAgentLoading flag is not verified by Spectre yet — the
JVM itself prints a stderr warning if it's missing, which is the source of truth. A follow-up
can add a reliable preflight via HotSpotDiagnosticMXBean.
Operation set¶
AttachedAutomator exposes the same operations as the HTTP transport, plus detach:
| Method | Wire op | Returns |
|---|---|---|
windows() |
AgentRequest.Windows |
List<WindowSummaryDto> |
allNodes() |
AgentRequest.AllNodes |
List<NodeSnapshotDto> |
findByTestTag(tag) |
AgentRequest.FindByTestTag |
List<NodeSnapshotDto> |
click(nodeKey) |
AgentRequest.Click |
Unit |
typeText(text) |
AgentRequest.TypeText |
Unit |
screenshot() |
AgentRequest.Screenshot |
ByteArray (PNG) |
close() (auto) |
AgentRequest.Detach |
tear-down |
Streaming / long-poll ops (waitForVisualIdle, idling resources, withTracing) are
deferred to a follow-up.
Wire format¶
Length-prefixed CBOR over the UDS:
DTOs live in dev.sebastiano.spectre.agent.transport.*. Both sides share the same
classes; CBOR's @SerialName discriminators pin each variant in the sealed-interface
hierarchy.
Current limitations¶
- macOS and Linux only. Windows support is a tracked follow-up.
- No streaming ops.
waitForVisualIdleand friends are HTTP-only or in-process only for now. - IntelliJ-hosted Compose: the classloader-disambiguation rule (D-14 in the plan) was
designed to handle
PluginClassLoaderchains but isn't automatically tested yet. If you hit issues attaching to an IntelliJ-hosted target, file a Spectre issue with theagent-attachlabel. - Runtime jar is separate from the API jar. The normal
spectre-agentdependency is not the jar loaded into the target JVM. Addspectre-agent-runtime, passAttachOptions.agentJarPath, or set-Ddev.sebastiano.spectre.agent.runtimeJar=....
Manual verification recipe¶
# Terminal A — start a Spectre-instrumented app
./gradlew :sample-desktop:run
# Find its PID
ps -A | grep "dev.sebastiano.spectre.sample.MainKt" | awk '{print $1}'
# Terminal B — attach the agent. The agent's stderr lands in Terminal A.
./gradlew :agent:attachSpike -Ppid=<pid>
The attachSpike task is intentionally separate from :check — it exists for human
verification and is not config-cache compatible.