From 562f9bb65ece90281cd44b63a17f0bbe32458c40 Mon Sep 17 00:00:00 2001 From: Mathieu Virbel Date: Thu, 26 Feb 2026 17:39:33 -0600 Subject: [PATCH] fix: preserve terminal env vars through sudo in macOS daemon mode sudo resets the environment, stripping TERM, COLORTERM, COLUMNS, LINES, and other terminal-related variables that TUI apps need to render. This caused TUI apps like opencode to show a blank screen in daemon mode. Fix by injecting terminal and proxy env vars via `env` after `sudo` in the daemon mode command pipeline. Also move PTY device ioctl/read/write rules into the base sandbox profile so inherited terminals work without requiring AllowPty. --- internal/sandbox/macos.go | 29 +++++++++++++++++------------ internal/sandbox/utils.go | 25 +++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 12 deletions(-) diff --git a/internal/sandbox/macos.go b/internal/sandbox/macos.go index f264cc5..200a6d7 100644 --- a/internal/sandbox/macos.go +++ b/internal/sandbox/macos.go @@ -556,6 +556,7 @@ func GenerateSandboxProfile(params MacOSSandboxParams) string { (allow file-ioctl (literal "/dev/urandom")) (allow file-ioctl (literal "/dev/dtracehelper")) (allow file-ioctl (literal "/dev/tty")) +(allow file-ioctl (regex #"^/dev/ttys")) (allow file-ioctl file-read-data file-write-data (require-all @@ -564,6 +565,9 @@ func GenerateSandboxProfile(params MacOSSandboxParams) string { ) ) +; Inherited terminal access (TUI apps need read/write on the actual PTY device) +(allow file-read-data file-write-data (regex #"^/dev/ttys")) + `) // Network rules @@ -630,19 +634,13 @@ func GenerateSandboxProfile(params MacOSSandboxParams) string { profile.WriteString(rule + "\n") } - // PTY support + // PTY allocation support (creating new pseudo-terminals) if params.AllowPty { profile.WriteString(` -; Pseudo-terminal (pty) support +; Pseudo-terminal allocation (pty) support (allow pseudo-tty) -(allow file-ioctl - (literal "/dev/ptmx") - (regex #"^/dev/ttys") -) -(allow file-read* file-write* - (literal "/dev/ptmx") - (regex #"^/dev/ttys") -) +(allow file-ioctl (literal "/dev/ptmx")) +(allow file-read* file-write* (literal "/dev/ptmx")) `) } @@ -738,9 +736,16 @@ func WrapCommandMacOS(cfg *config.Config, command string, exposedPorts []int, da // pf routes all traffic from group _greywall through utun → tun2socks → proxy. // Using -u # preserves the user's identity (home dir, SSH keys, etc.) // while -g _greywall sets the effective GID for pf matching. + // + // sudo resets the environment, so we use `env` after sudo to re-inject + // terminal vars (TERM, COLORTERM, etc.) needed for TUI apps and proxy vars. uid := fmt.Sprintf("#%d", os.Getuid()) - parts = append(parts, "sudo", "-u", uid, "-g", daemonSession.SandboxGroup, - "sandbox-exec", "-p", profile, shellPath, "-c", command) + proxyEnvs := GenerateProxyEnvVars(cfg.Network.ProxyURL) + termEnvs := getTerminalEnvVars() + parts = append(parts, "sudo", "-u", uid, "-g", daemonSession.SandboxGroup, "env") + parts = append(parts, proxyEnvs...) + parts = append(parts, termEnvs...) + parts = append(parts, "sandbox-exec", "-p", profile, shellPath, "-c", command) } else { // Non-daemon mode: use proxy env vars for best-effort proxying. proxyEnvs := GenerateProxyEnvVars(cfg.Network.ProxyURL) diff --git a/internal/sandbox/utils.go b/internal/sandbox/utils.go index 4b888cd..319f160 100644 --- a/internal/sandbox/utils.go +++ b/internal/sandbox/utils.go @@ -86,6 +86,31 @@ func GenerateProxyEnvVars(proxyURL string) []string { return envVars } +// getTerminalEnvVars returns KEY=VALUE entries for terminal-related environment +// variables that are set in the current process. These must be re-injected after +// sudo (which resets the environment) so that TUI apps can detect terminal +// capabilities, size, and color support. +func getTerminalEnvVars() []string { + termVars := []string{ + "TERM", + "COLORTERM", + "COLUMNS", + "LINES", + "TERMINFO", + "TERMINFO_DIRS", + "LANG", + "LC_ALL", + "LC_CTYPE", + } + var envs []string + for _, key := range termVars { + if val := os.Getenv(key); val != "" { + envs = append(envs, key+"="+val) + } + } + return envs +} + // EncodeSandboxedCommand encodes a command for sandbox monitoring. func EncodeSandboxedCommand(command string) string { if len(command) > 100 {