Skip to content

Commit 407163d

Browse files
bcollardclaude
andauthored
fix(doctor): warn when the hostagent's binary was replaced under it (#17)
#14 made the binary-replacement check work when the hostagent runs a different path than the current klimax. It still could not see the case that actually causes the failure: the same path, replaced underneath a running process. `make dev-install` does `sudo cp` over /usr/local/bin/klimax, overwriting the file the hostagent is executing — which is what makes macOS amfid kill subsequent klimax execs. Both sides of a path comparison still read "/usr/local/bin/klimax", so doctor reported OK while the machine was in exactly the broken state the check exists to catch. Compare timestamps instead: if the binary's mtime is later than the process start time, it was replaced after launch. Process start comes from the kernel proc table via sysctl — there is no /proc on macOS, and `ps` only offers a locale-formatted date. A Homebrew upgrade does NOT trigger amfid, because it writes a new file into the Caskroom and re-points the symlink, so the running process keeps its own inode. It does still trip this check — correctly: the hostagent is running an older klimax and the VM wants rebuilding either way. Hence a warning, never a failure; mtime is a heuristic and a rebuild producing identical content would also trip it. processStartTime is darwin-only and split across build tags, since internal/cli builds for linux today and must keep doing so. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01298bsrxR44xPnT3qM9fu2D
1 parent 2044b36 commit 407163d

5 files changed

Lines changed: 131 additions & 1 deletion

File tree

‎go.mod‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ require (
88
github.com/sirupsen/logrus v1.10.2
99
github.com/spf13/cobra v1.10.2
1010
golang.org/x/crypto v0.56.0
11+
golang.org/x/sys v0.47.0
1112
golang.org/x/term v0.45.0
1213
gopkg.in/yaml.v3 v3.0.1
1314
)
@@ -82,7 +83,6 @@ require (
8283
golang.org/x/mod v0.38.0 // indirect
8384
golang.org/x/net v0.57.0 // indirect
8485
golang.org/x/sync v0.22.0 // indirect
85-
golang.org/x/sys v0.47.0 // indirect
8686
golang.org/x/text v0.41.0 // indirect
8787
golang.org/x/time v0.9.0 // indirect
8888
golang.org/x/tools v0.48.0 // indirect

‎internal/cli/doctor.go‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"strconv"
1313
"strings"
1414
"syscall"
15+
"time"
1516

1617
"github.com/bcollard/klimax/internal/config"
1718
"github.com/bcollard/klimax/internal/guest"
@@ -452,6 +453,16 @@ func checkHostagent(instanceName string) *doctorCheck {
452453

453454
self, _ := os.Executable()
454455
if sameFile(exe, self) {
456+
// Same path — but the file at that path may have been replaced since the
457+
// hostagent started, in which case it is running code that no longer
458+
// exists on disk. Path comparison cannot see this; timestamps can.
459+
if replaced, mtime, start := binaryReplacedSinceLaunch(exe, pid); replaced {
460+
return &doctorCheck{ID: checkIDHostagent, Status: checkWarn,
461+
Message: fmt.Sprintf("hostagent (pid %d) is running a binary that has since been replaced", pid),
462+
Detail: fmt.Sprintf("%s was modified at %s, after the hostagent started at %s.\n It is still running the old code; klimax execs may fail with 'killed'.",
463+
exe, mtime.Format(time.RFC3339), start.Format(time.RFC3339)),
464+
Fix: "klimax down && klimax up (or: " + cleanup + ")"}
465+
}
455466
return &doctorCheck{ID: checkIDHostagent, Status: checkOK,
456467
Message: fmt.Sprintf("hostagent is running (pid %d)", pid)}
457468
}
@@ -507,3 +518,31 @@ func sameFile(a, b string) bool {
507518
}
508519
return ra == rb
509520
}
521+
522+
// binaryReplacedSinceLaunch reports whether the file at path was modified after
523+
// the process started — i.e. the running process is executing code that path no
524+
// longer contains.
525+
//
526+
// This is the case plain path comparison cannot see. `make dev-install` does
527+
// `sudo cp` over /usr/local/bin/klimax, overwriting the file underneath the
528+
// running hostagent, which is what makes macOS amfid kill subsequent execs. Both
529+
// sides of a path comparison still read the same string, so only the timestamps
530+
// give it away.
531+
//
532+
// A Homebrew upgrade is different — it writes a new file into the Caskroom and
533+
// re-points the symlink, so the running process keeps its own inode and amfid is
534+
// not upset. That still trips this check, and rightly so: the hostagent is
535+
// running an older klimax and the VM wants rebuilding either way. Hence a
536+
// warning, never a failure — and mtime is a heuristic, so a rebuild producing
537+
// identical content would also trip it.
538+
func binaryReplacedSinceLaunch(path string, pid int) (replaced bool, mtime, start time.Time) {
539+
start, err := processStartTime(pid)
540+
if err != nil {
541+
return false, time.Time{}, time.Time{} // cannot tell — stay quiet
542+
}
543+
fi, err := os.Stat(path)
544+
if err != nil {
545+
return false, time.Time{}, time.Time{}
546+
}
547+
return fi.ModTime().After(start), fi.ModTime(), start
548+
}

‎internal/cli/doctor_test.go‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package cli
33
import (
44
"os"
55
"path/filepath"
6+
"time"
67
"testing"
78
)
89

@@ -164,3 +165,57 @@ func TestSameFileFollowsSymlinks(t *testing.T) {
164165
t.Error("two nonexistent paths must not compare equal")
165166
}
166167
}
168+
169+
func TestProcessStartTimeIsSane(t *testing.T) {
170+
start, err := processStartTime(os.Getpid())
171+
if err != nil {
172+
t.Skipf("process start time unavailable on this platform: %v", err)
173+
}
174+
if start.IsZero() || start.After(time.Now()) {
175+
t.Errorf("implausible start time %v", start)
176+
}
177+
// The test binary cannot have been running for a year.
178+
if time.Since(start) > 365*24*time.Hour {
179+
t.Errorf("start time %v is implausibly old", start)
180+
}
181+
}
182+
183+
// The case plain path comparison cannot see: same path, but the file behind it
184+
// changed after the process launched.
185+
func TestBinaryReplacedSinceLaunch(t *testing.T) {
186+
if _, err := processStartTime(os.Getpid()); err != nil {
187+
t.Skipf("process start time unavailable on this platform: %v", err)
188+
}
189+
dir := t.TempDir()
190+
191+
// Written now, so newer than this process — the "replaced" case.
192+
fresh := filepath.Join(dir, "fresh")
193+
if err := os.WriteFile(fresh, []byte("new"), 0o755); err != nil {
194+
t.Fatal(err)
195+
}
196+
if replaced, _, _ := binaryReplacedSinceLaunch(fresh, os.Getpid()); !replaced {
197+
t.Error("a file written after the process started must count as replaced")
198+
}
199+
200+
// Backdated well before the process started — the normal case.
201+
old := filepath.Join(dir, "old")
202+
if err := os.WriteFile(old, []byte("old"), 0o755); err != nil {
203+
t.Fatal(err)
204+
}
205+
past := time.Now().Add(-48 * time.Hour)
206+
if err := os.Chtimes(old, past, past); err != nil {
207+
t.Fatal(err)
208+
}
209+
if replaced, _, _ := binaryReplacedSinceLaunch(old, os.Getpid()); replaced {
210+
t.Error("a file older than the process must not count as replaced")
211+
}
212+
213+
// Unreadable path must not produce a false warning.
214+
if replaced, _, _ := binaryReplacedSinceLaunch(filepath.Join(dir, "nope"), os.Getpid()); replaced {
215+
t.Error("a missing file must not count as replaced")
216+
}
217+
// An impossible pid means we cannot tell — must stay quiet.
218+
if replaced, _, _ := binaryReplacedSinceLaunch(fresh, 0x7FFFFFFE); replaced {
219+
t.Error("an unknown process must not produce a warning")
220+
}
221+
}

‎internal/cli/proctime_darwin.go‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
//go:build darwin
2+
3+
package cli
4+
5+
import (
6+
"time"
7+
8+
"golang.org/x/sys/unix"
9+
)
10+
11+
// processStartTime returns when pid was launched, read from the kernel's proc
12+
// table. There is no /proc on macOS, and `ps` only reports a locale-formatted
13+
// date, so go straight to the sysctl.
14+
func processStartTime(pid int) (time.Time, error) {
15+
kp, err := unix.SysctlKinfoProc("kern.proc.pid", pid)
16+
if err != nil {
17+
return time.Time{}, err
18+
}
19+
tv := kp.Proc.P_starttime
20+
return time.Unix(tv.Sec, int64(tv.Usec)*1000), nil
21+
}

‎internal/cli/proctime_other.go‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
//go:build !darwin
2+
3+
package cli
4+
5+
import (
6+
"errors"
7+
"time"
8+
)
9+
10+
// processStartTime is implemented for darwin only. klimax targets macOS, and the
11+
// staleness check that uses it exists for a macOS failure mode; elsewhere the
12+
// caller treats the error as "cannot tell" and skips the check.
13+
func processStartTime(int) (time.Time, error) {
14+
return time.Time{}, errors.New("process start time is only available on darwin")
15+
}

0 commit comments

Comments
 (0)