Skip to content

Commit 716afae

Browse files
fix(mdm): detect logged-in console user when running as root
1 parent c231c20 commit 716afae

9 files changed

Lines changed: 141 additions & 20 deletions

File tree

internal/detector/aicli.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ func expandTilde(path, homeDir string) string {
196196
}
197197

198198
func getHomeDir(exec executor.Executor) string {
199-
u, err := exec.CurrentUser()
199+
u, err := exec.LoggedInUser()
200200
if err != nil {
201201
return os.TempDir()
202202
}

internal/detector/nodescan.go

Lines changed: 70 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package detector
33
import (
44
"context"
55
"encoding/base64"
6+
"fmt"
67
"os"
78
"path/filepath"
89
"sort"
@@ -30,12 +31,70 @@ func getMaxProjectScanBytes() int64 {
3031

3132
// NodeScanner performs enterprise-mode node scanning (raw output, base64 encoded).
3233
type NodeScanner struct {
33-
exec executor.Executor
34-
log *progress.Logger
34+
exec executor.Executor
35+
log *progress.Logger
36+
loggedInUser string // when non-empty and running as root, commands run as this user
3537
}
3638

37-
func NewNodeScanner(exec executor.Executor, log *progress.Logger) *NodeScanner {
38-
return &NodeScanner{exec: exec, log: log}
39+
func NewNodeScanner(exec executor.Executor, log *progress.Logger, loggedInUser string) *NodeScanner {
40+
return &NodeScanner{exec: exec, log: log, loggedInUser: loggedInUser}
41+
}
42+
43+
// shouldRunAsUser returns true when commands should be delegated to the logged-in user.
44+
func (s *NodeScanner) shouldRunAsUser() bool {
45+
return s.exec.IsRoot() && s.loggedInUser != ""
46+
}
47+
48+
// runCmd runs a command, delegating to the logged-in user when running as root.
49+
// This ensures package manager commands use the real user's PATH and config.
50+
func (s *NodeScanner) runCmd(ctx context.Context, timeout time.Duration, name string, args ...string) (string, string, int, error) {
51+
if s.shouldRunAsUser() {
52+
ctx, cancel := context.WithTimeout(ctx, timeout)
53+
defer cancel()
54+
cmd := name
55+
for _, a := range args {
56+
cmd += " " + a
57+
}
58+
stdout, err := s.exec.RunAsUser(ctx, s.loggedInUser, cmd)
59+
if err != nil {
60+
if ctx.Err() == context.DeadlineExceeded {
61+
return stdout, "", 124, fmt.Errorf("command timed out after %s", timeout)
62+
}
63+
return stdout, "", 1, err
64+
}
65+
return stdout, "", 0, nil
66+
}
67+
return s.exec.RunWithTimeout(ctx, timeout, name, args...)
68+
}
69+
70+
// runShellCmd runs a shell command string, delegating to the logged-in user when running as root.
71+
func (s *NodeScanner) runShellCmd(ctx context.Context, timeout time.Duration, shellCmd string) (string, string, int, error) {
72+
if s.shouldRunAsUser() {
73+
ctx, cancel := context.WithTimeout(ctx, timeout)
74+
defer cancel()
75+
stdout, err := s.exec.RunAsUser(ctx, s.loggedInUser, shellCmd)
76+
if err != nil {
77+
if ctx.Err() == context.DeadlineExceeded {
78+
return stdout, "", 124, fmt.Errorf("command timed out after %s", timeout)
79+
}
80+
return stdout, "", 1, err
81+
}
82+
return stdout, "", 0, nil
83+
}
84+
return s.exec.RunWithTimeout(ctx, timeout, "bash", "-c", shellCmd)
85+
}
86+
87+
// checkPath checks if a binary is available, using the logged-in user's PATH when running as root.
88+
func (s *NodeScanner) checkPath(ctx context.Context, name string) error {
89+
if s.shouldRunAsUser() {
90+
path, err := s.exec.RunAsUser(ctx, s.loggedInUser, "which "+name)
91+
if err != nil || path == "" {
92+
return fmt.Errorf("%s not found in user PATH", name)
93+
}
94+
return nil
95+
}
96+
_, err := s.exec.LookPath(name)
97+
return err
3998
}
4099

41100
// ScanGlobalPackages runs npm/yarn/pnpm list -g and returns raw base64-encoded results.
@@ -61,7 +120,7 @@ func (s *NodeScanner) ScanGlobalPackages(ctx context.Context) []model.NodeScanRe
61120
}
62121

63122
func (s *NodeScanner) scanNPMGlobal(ctx context.Context) (model.NodeScanResult, bool) {
64-
if _, err := s.exec.LookPath("npm"); err != nil {
123+
if err := s.checkPath(ctx, "npm"); err != nil {
65124
return model.NodeScanResult{}, false
66125
}
67126

@@ -72,7 +131,7 @@ func (s *NodeScanner) scanNPMGlobal(ctx context.Context) (model.NodeScanResult,
72131
}
73132

74133
start := time.Now()
75-
stdout, stderr, exitCode, _ := s.exec.RunWithTimeout(ctx, 60*time.Second, "npm", "list", "-g", "--json", "--depth=3")
134+
stdout, stderr, exitCode, _ := s.runCmd(ctx, 60*time.Second, "npm", "list", "-g", "--json", "--depth=3")
76135
duration := time.Since(start).Milliseconds()
77136

78137
errMsg := ""
@@ -94,7 +153,7 @@ func (s *NodeScanner) scanNPMGlobal(ctx context.Context) (model.NodeScanResult,
94153
}
95154

96155
func (s *NodeScanner) scanYarnGlobal(ctx context.Context) (model.NodeScanResult, bool) {
97-
if _, err := s.exec.LookPath("yarn"); err != nil {
156+
if err := s.checkPath(ctx, "yarn"); err != nil {
98157
return model.NodeScanResult{}, false
99158
}
100159

@@ -128,7 +187,7 @@ func (s *NodeScanner) scanYarnGlobal(ctx context.Context) (model.NodeScanResult,
128187
}
129188

130189
func (s *NodeScanner) scanPnpmGlobal(ctx context.Context) (model.NodeScanResult, bool) {
131-
if _, err := s.exec.LookPath("pnpm"); err != nil {
190+
if err := s.checkPath(ctx, "pnpm"); err != nil {
132191
return model.NodeScanResult{}, false
133192
}
134193

@@ -140,7 +199,7 @@ func (s *NodeScanner) scanPnpmGlobal(ctx context.Context) (model.NodeScanResult,
140199
globalDir = filepath.Dir(globalDir)
141200

142201
start := time.Now()
143-
stdout, stderr, exitCode, _ := s.exec.RunWithTimeout(ctx, 60*time.Second, "pnpm", "list", "-g", "--json", "--depth=3")
202+
stdout, stderr, exitCode, _ := s.runCmd(ctx, 60*time.Second, "pnpm", "list", "-g", "--json", "--depth=3")
144203
duration := time.Since(start).Milliseconds()
145204

146205
errMsg := ""
@@ -308,15 +367,15 @@ func (s *NodeScanner) scanProject(ctx context.Context, projectDir string) model.
308367
}
309368

310369
func (s *NodeScanner) getVersion(ctx context.Context, binary, flag string) string {
311-
stdout, _, _, err := s.exec.RunWithTimeout(ctx, 10*time.Second, binary, flag)
370+
stdout, _, _, err := s.runCmd(ctx, 10*time.Second, binary, flag)
312371
if err != nil {
313372
return "unknown"
314373
}
315374
return strings.TrimSpace(stdout)
316375
}
317376

318377
func (s *NodeScanner) getOutput(ctx context.Context, binary string, args ...string) string {
319-
stdout, _, _, err := s.exec.RunWithTimeout(ctx, 10*time.Second, binary, args...)
378+
stdout, _, _, err := s.runCmd(ctx, 10*time.Second, binary, args...)
320379
if err != nil {
321380
return ""
322381
}

internal/device/device.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -124,8 +124,8 @@ func getDeveloperIdentity(exec executor.Executor) string {
124124
return v
125125
}
126126
}
127-
// Fallback to current username
128-
u, err := exec.CurrentUser()
127+
// Fallback to logged-in username (detects console user when running as root)
128+
u, err := exec.LoggedInUser()
129129
if err == nil {
130130
return u.Username
131131
}

internal/executor/executor.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,11 @@ type Executor interface {
4545
HomeDir(username string) (string, error)
4646
// Glob returns filenames matching a pattern.
4747
Glob(pattern string) ([]string, error)
48+
// LoggedInUser returns the actual logged-in console user.
49+
// When running as root on macOS (e.g., via LaunchDaemon), this detects the
50+
// real console user via /dev/console rather than returning root.
51+
// Falls back to CurrentUser() when not root or on non-macOS platforms.
52+
LoggedInUser() (*user.User, error)
4853
// GOOS returns the runtime operating system.
4954
GOOS() string
5055
}
@@ -131,6 +136,33 @@ func (r *Real) Glob(pattern string) ([]string, error) {
131136
return filepath.Glob(pattern)
132137
}
133138

139+
func (r *Real) LoggedInUser() (*user.User, error) {
140+
if runtime.GOOS != "darwin" || !r.IsRoot() {
141+
return r.CurrentUser()
142+
}
143+
144+
// On macOS running as root, detect the console user.
145+
// This mirrors the bash script's get_logged_in_user_info() which uses
146+
// stat -f%Su /dev/console to find who is actually logged in.
147+
ctx := context.Background()
148+
stdout, _, _, err := r.Run(ctx, "stat", "-f%Su", "/dev/console")
149+
if err != nil {
150+
return r.CurrentUser()
151+
}
152+
153+
username := strings.TrimSpace(stdout)
154+
if username == "" || username == "root" || username == "_windowserver" {
155+
return r.CurrentUser()
156+
}
157+
158+
u, err := user.Lookup(username)
159+
if err != nil {
160+
return r.CurrentUser()
161+
}
162+
163+
return u, nil
164+
}
165+
134166
func (r *Real) GOOS() string {
135167
return runtime.GOOS
136168
}

internal/executor/mock.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,10 @@ func (m *Mock) Glob(pattern string) ([]string, error) {
267267
return nil, nil
268268
}
269269

270+
func (m *Mock) LoggedInUser() (*user.User, error) {
271+
return m.CurrentUser()
272+
}
273+
270274
func (m *Mock) GOOS() string {
271275
m.mu.RLock()
272276
defer m.mu.RUnlock()

internal/launchd/launchd.go

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,12 +68,24 @@ func Install(exec executor.Executor, log *progress.Logger) error {
6868
}
6969
}
7070

71+
// Resolve the real user's home directory for the plist.
72+
// When running as root (LaunchDaemon), launchd provides a minimal environment
73+
// without HOME, so os.UserHomeDir() would fail at runtime. We detect the
74+
// logged-in console user now and bake their HOME into the plist.
75+
userHome := ""
76+
if exec.IsRoot() {
77+
if u, err := exec.LoggedInUser(); err == nil {
78+
userHome = u.HomeDir
79+
}
80+
}
81+
7182
// Generate plist
7283
plistData := plistTemplateData{
7384
Label: label,
7485
BinaryPath: binaryPath,
7586
IntervalSeconds: intervalSeconds,
7687
LogDir: logDir,
88+
UserHome: userHome,
7789
}
7890

7991
f, err := os.Create(plistPath)
@@ -163,6 +175,7 @@ type plistTemplateData struct {
163175
BinaryPath string
164176
IntervalSeconds int
165177
LogDir string
178+
UserHome string // non-empty when running as root; baked into plist as HOME env var
166179
}
167180

168181
const plistTmpl = `<?xml version="1.0" encoding="UTF-8"?>
@@ -179,7 +192,12 @@ const plistTmpl = `<?xml version="1.0" encoding="UTF-8"?>
179192
<key>StartInterval</key>
180193
<integer>{{.IntervalSeconds}}</integer>
181194
<key>RunAtLoad</key>
182-
<false/>
195+
<false/>{{if .UserHome}}
196+
<key>EnvironmentVariables</key>
197+
<dict>
198+
<key>HOME</key>
199+
<string>{{.UserHome}}</string>
200+
</dict>{{end}}
183201
<key>StandardOutPath</key>
184202
<string>{{.LogDir}}/agent.log</string>
185203
<key>StandardErrorPath</key>

internal/progress/progress.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,15 @@ func (l *Logger) Progress(format string, args ...any) {
3535
if l.quiet {
3636
return
3737
}
38-
fmt.Fprintf(os.Stderr, "\033[2m[scanning]\033[0m %s\n", fmt.Sprintf(format, args...))
38+
ts := time.Now().Format("2006-01-02 15:04:05")
39+
fmt.Fprintf(os.Stderr, "\033[2m%s [scanning]\033[0m %s\n", ts, fmt.Sprintf(format, args...))
3940
}
4041

4142
// Error always prints to stderr regardless of quiet mode.
4243
// Format: [error] message
4344
func (l *Logger) Error(format string, args ...any) {
44-
fmt.Fprintf(os.Stderr, "\033[0;31m[error]\033[0m %s\n", fmt.Sprintf(format, args...))
45+
ts := time.Now().Format("2006-01-02 15:04:05")
46+
fmt.Fprintf(os.Stderr, "%s \033[0;31m[error]\033[0m %s\n", ts, fmt.Sprintf(format, args...))
4547
}
4648

4749
// StepStart begins a labeled progress step with a spinner.

internal/scan/scanner.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ func resolveSearchDirs(exec executor.Executor, dirs []string) []string {
140140
resolved := make([]string, 0, len(dirs))
141141
for _, d := range dirs {
142142
if d == "$HOME" {
143-
u, err := exec.CurrentUser()
143+
u, err := exec.LoggedInUser()
144144
if err == nil {
145145
d = u.HomeDir
146146
}

internal/telemetry/telemetry.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,12 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) error {
103103
log.Progress("OS Version: %s", dev.OSVersion)
104104
log.Progress("Developer: %s", dev.UserIdentity)
105105

106+
// Detect logged-in user for running commands as the real user when root
107+
loggedInUsername := ""
108+
if loggedInUser, err := exec.LoggedInUser(); err == nil {
109+
loggedInUsername = loggedInUser.Username
110+
}
111+
106112
// Resolve search dirs
107113
searchDirs := resolveSearchDirs(exec, cfg.SearchDirs)
108114
fmt.Fprintln(os.Stderr)
@@ -201,7 +207,7 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) error {
201207
fmt.Fprintln(os.Stderr)
202208

203209
log.Progress("Scanning globally installed packages...")
204-
nodeScanner := detector.NewNodeScanner(exec, log)
210+
nodeScanner := detector.NewNodeScanner(exec, log, loggedInUsername)
205211
globalPkgs = nodeScanner.ScanGlobalPackages(ctx)
206212
log.Progress(" Found %d global package location(s)", len(globalPkgs))
207213
fmt.Fprintln(os.Stderr)
@@ -375,7 +381,7 @@ func resolveSearchDirs(exec executor.Executor, dirs []string) []string {
375381
resolved := make([]string, 0, len(dirs))
376382
for _, d := range dirs {
377383
if d == "$HOME" {
378-
u, err := exec.CurrentUser()
384+
u, err := exec.LoggedInUser()
379385
if err == nil {
380386
d = u.HomeDir
381387
}

0 commit comments

Comments
 (0)