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.
This commit is contained in:
2026-02-26 17:39:33 -06:00
parent 9d5d852860
commit 562f9bb65e
2 changed files with 42 additions and 12 deletions

View File

@@ -556,6 +556,7 @@ func GenerateSandboxProfile(params MacOSSandboxParams) string {
(allow file-ioctl (literal "/dev/urandom")) (allow file-ioctl (literal "/dev/urandom"))
(allow file-ioctl (literal "/dev/dtracehelper")) (allow file-ioctl (literal "/dev/dtracehelper"))
(allow file-ioctl (literal "/dev/tty")) (allow file-ioctl (literal "/dev/tty"))
(allow file-ioctl (regex #"^/dev/ttys"))
(allow file-ioctl file-read-data file-write-data (allow file-ioctl file-read-data file-write-data
(require-all (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 // Network rules
@@ -630,19 +634,13 @@ func GenerateSandboxProfile(params MacOSSandboxParams) string {
profile.WriteString(rule + "\n") profile.WriteString(rule + "\n")
} }
// PTY support // PTY allocation support (creating new pseudo-terminals)
if params.AllowPty { if params.AllowPty {
profile.WriteString(` profile.WriteString(`
; Pseudo-terminal (pty) support ; Pseudo-terminal allocation (pty) support
(allow pseudo-tty) (allow pseudo-tty)
(allow file-ioctl (allow file-ioctl (literal "/dev/ptmx"))
(literal "/dev/ptmx") (allow file-read* file-write* (literal "/dev/ptmx"))
(regex #"^/dev/ttys")
)
(allow file-read* file-write*
(literal "/dev/ptmx")
(regex #"^/dev/ttys")
)
`) `)
} }
@@ -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. // pf routes all traffic from group _greywall through utun → tun2socks → proxy.
// Using -u #<uid> preserves the user's identity (home dir, SSH keys, etc.) // Using -u #<uid> preserves the user's identity (home dir, SSH keys, etc.)
// while -g _greywall sets the effective GID for pf matching. // 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()) uid := fmt.Sprintf("#%d", os.Getuid())
parts = append(parts, "sudo", "-u", uid, "-g", daemonSession.SandboxGroup, proxyEnvs := GenerateProxyEnvVars(cfg.Network.ProxyURL)
"sandbox-exec", "-p", profile, shellPath, "-c", command) 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 { } else {
// Non-daemon mode: use proxy env vars for best-effort proxying. // Non-daemon mode: use proxy env vars for best-effort proxying.
proxyEnvs := GenerateProxyEnvVars(cfg.Network.ProxyURL) proxyEnvs := GenerateProxyEnvVars(cfg.Network.ProxyURL)

View File

@@ -86,6 +86,31 @@ func GenerateProxyEnvVars(proxyURL string) []string {
return envVars 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. // EncodeSandboxedCommand encodes a command for sandbox monitoring.
func EncodeSandboxedCommand(command string) string { func EncodeSandboxedCommand(command string) string {
if len(command) > 100 { if len(command) > 100 {