Skip to content

Commit 0d0374e

Browse files
authored
nix: inherit all environment variables in shell (#706)
This is a long description, but I wanted to document the exact changes being made to Devbox environments for reference. ## Context Before release v0.4.0, devbox handled environment variables differently depending on the command: - `devbox shell` would start with a clean environment. Nothing was inherited from the parent shell (but shellrc files would still be sourced) - `devbox shell -- cmd` would inherit the environment. - `devbox run` would inherit the environment. With v0.4.0, all commands adopted the `devbox shell` behavior of starting with a clean environment. However, this broke some existing users and the workaround of manually specifying environment variables to inherit would be cumbersome. As discussed, this change flips the behavior such that all commands will now inherit their parent shell's environment. Eventually there will be a flag to enable an "isolated" mode to restore the previous behavior. ## Changes to `devbox shell` and `devbox run` environments Devbox environments are now built with environment variables from the following steps: 1. Variables exported in the user's `~/.bashrc`, `~/.zshrc`, etc. 2. Variables from the current environment except for PWD, OLDPWD, SHLVL, and SHELL. 3. Variables from the `nix print-dev-env` environment except for BASHOPTS, HOME, NIX_BUILD_TOP, NIX_ENFORCE_PURITY, NIX_LOG_FD, NIX_REMOTE, PPID, SHELL, SHELLOPTS, TEMP, TEMPDIR, TERM, TMP, TMPDIR, TZ, and UID. The SSL_CERT_FILE variable is also ignored if it's set to `/no-cert-file.crt`. 4. Variables from Devbox plugins. 5. PATH is set to the concatenation of the PATHs from step 4, step 3, and step 2 (in that order). The PATH from the user's init files in step 1 is overwritten. The final PATH is cleaned and deduplicated. 5. HISTFILE is set to a Devbox project-specific file. 6. PS1 is set to "(devbox) $PS1" Each step adds to the previous step, overwriting any keys that already exist. ## Bug fixes and improvements - As suggested by @ipince, `shellrc.tmpl` simply sources the user's rcfile instead of copying it. - The result of `PrintEnv` now escapes all environment variable values. This means that values such as `FOO=$(echo bar)` will become `FOO="\$(echo bar)"` instead of being evaluated by the shell. Allowing variables and command substitution would require implementing a full parser to determine which characters to escape. - Some unquoted variables in `shellrc.tmpl` are now quoted so that devbox directories with spaces work. - The `cd` commands in `shellrc.tmpl` exit on failure to prevent running further commands in the wrong directory.
1 parent 706ac42 commit 0d0374e

File tree

18 files changed

+242
-473
lines changed

18 files changed

+242
-473
lines changed

devbox.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ type Devbox interface {
3131
GenerateEnvrc(force bool, source string) error
3232
Info(pkg string, markdown bool) error
3333
ListScripts() []string
34-
PrintEnv(setFullPath bool) (string, error)
34+
PrintEnv() (string, error)
3535
PrintGlobalList() error
3636
PullGlobal(path string) error
3737
// Remove removes Nix packages from the config so that it no longer exists in

internal/boxcli/shell.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ func runShellCmd(cmd *cobra.Command, args []string, flags shellCmdFlags) error {
6565
}
6666

6767
if flags.PrintEnv {
68-
script, err := box.PrintEnv(false)
68+
script, err := box.PrintEnv()
6969
if err != nil {
7070
return err
7171
}

internal/boxcli/shellenv.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,5 +41,5 @@ func shellEnvFunc(cmd *cobra.Command, flags shellEnvCmdFlags) (string, error) {
4141
return "", err
4242
}
4343

44-
return box.PrintEnv(true)
44+
return box.PrintEnv()
4545
}

internal/impl/devbox.go

Lines changed: 112 additions & 122 deletions
Original file line numberDiff line numberDiff line change
@@ -244,13 +244,14 @@ func (d *Devbox) Shell() error {
244244
return err
245245
}
246246

247-
env, err := plugin.Env(d.packages(), d.projectDir)
248-
if err != nil {
249-
return err
250-
}
251-
247+
var env map[string]string
252248
if featureflag.UnifiedEnv.Enabled() {
253-
env, err = d.computeNixEnv(false)
249+
env, err = d.computeNixEnv()
250+
if err != nil {
251+
return err
252+
}
253+
} else {
254+
env, err = plugin.Env(d.packages(), d.projectDir)
254255
if err != nil {
255256
return err
256257
}
@@ -293,7 +294,7 @@ func (d *Devbox) RunScript(cmdName string, cmdArgs []string) error {
293294
return err
294295
}
295296

296-
env, err := d.computeNixEnv(true)
297+
env, err := d.computeNixEnv()
297298
if err != nil {
298299
return err
299300
}
@@ -443,7 +444,7 @@ func (d *Devbox) Exec(cmds ...string) error {
443444
}
444445
}
445446

446-
func (d *Devbox) PrintEnv(setFullPath bool) (string, error) {
447+
func (d *Devbox) PrintEnv() (string, error) {
447448
script := ""
448449
if featureflag.UnifiedEnv.Disabled() {
449450
envs, err := plugin.Env(d.packages(), d.projectDir)
@@ -455,7 +456,7 @@ func (d *Devbox) PrintEnv(setFullPath bool) (string, error) {
455456
}
456457
return script, nil
457458
}
458-
envs, err := d.computeNixEnv(setFullPath)
459+
envs, err := d.computeNixEnv()
459460
if err != nil {
460461
return "", err
461462
}
@@ -748,66 +749,85 @@ func (d *Devbox) printPackageUpdateMessage(
748749
return nil
749750
}
750751

751-
// computeNixEnv computes the environment (i.e. set of env variables) to be used on
752-
// devbox execution commands (i.e. devbox run, shell). In short, the environment is
753-
// calculated as follows:
754-
// 1. Start with the output of nix print-dev-env
755-
// 2. Allow a limited set of variables (e.g. leakedVars) in the host machine to "leak" in (e.g. HOME).
756-
// 3. Include any plugin env vars.
757-
// 4. Include any user-defined env vars from devbox.json.
752+
// computeNixEnv computes the set of environment variables that define a Devbox
753+
// environment. The "devbox run" and "devbox shell" commands source these
754+
// variables into a shell before executing a command or showing an interactive
755+
// prompt.
756+
//
757+
// The process for building the environment involves layering sets of
758+
// environment variables on top of each other, with each layer overwriting any
759+
// duplicate keys from the previous:
758760
//
759-
// The PATH variable has some special handling. In short:
760-
// 1. Start with the PATH as defined by nix (through nix print-dev-env).
761-
// 2. Clean the host PATH of any nix paths.
762-
// 3. Append the cleaned host PATH (tradeoff between reproducibility and ease of use).
763-
// 4. Prepend the paths of any plugins (tbd whether it's actually needed).
764-
func (d *Devbox) computeNixEnv(setFullPath bool) (map[string]string, error) {
761+
// 1. Copy variables from the current environment except for those in
762+
// ignoreCurrentEnvVar, such as PWD and SHELL.
763+
// 2. Copy variables from "nix print-dev-env" except for those in
764+
// ignoreDevEnvVar, such as TMPDIR and HOME.
765+
// 3. Copy variables from Devbox plugins.
766+
// 4. Set PATH to the concatenation of the PATHs from step 3, step 2, and
767+
// step 1 (in that order).
768+
//
769+
// The final result is a set of environment variables where Devbox plugins have
770+
// the highest priority, then Nix environment variables, and then variables
771+
// from the current environment. Similarly, the PATH gives Devbox plugin
772+
// binaries the highest priority, then Nix packages, and then non-Nix
773+
// programs.
774+
//
775+
// Note that the shellrc.tmpl template (which sources this environment) does
776+
// some additional processing. The computeNixEnv environment won't necessarily
777+
// represent the final "devbox run" or "devbox shell" environments.
778+
func (d *Devbox) computeNixEnv() (map[string]string, error) {
779+
currentEnv := os.Environ()
780+
env := make(map[string]string, len(currentEnv))
781+
for _, kv := range currentEnv {
782+
key, val, found := strings.Cut(kv, "=")
783+
if !found {
784+
return nil, errors.Errorf("expected \"=\" in keyval: %s", kv)
785+
}
786+
if ignoreCurrentEnvVar[key] {
787+
continue
788+
}
789+
env[key] = val
790+
}
791+
currentEnvPath := env["PATH"]
792+
debug.Log("current environment PATH is: %s", currentEnvPath)
765793

766794
vaf, err := nix.PrintDevEnv(d.nixShellFilePath(), d.nixFlakesFilePath())
767795
if err != nil {
768796
return nil, err
769797
}
770798

771-
env := map[string]string{}
772-
for k, v := range vaf.Variables {
799+
// Add environment variables from "nix print-dev-env" except for a few
800+
// special ones we need to ignore.
801+
for key, val := range vaf.Variables {
773802
// We only care about "exported" because the var and array types seem to only be used by nix-defined
774803
// functions that we don't need (like genericBuild). For reference, each type translates to bash as follows:
775804
// var: export VAR=VAL
776805
// exported: export VAR=VAL
777806
// array: declare -a VAR=('VAL1' 'VAL2' )
778-
if v.Type == "exported" {
779-
env[k] = v.Value.(string)
780-
}
781-
}
782-
783-
// Hack to quickly fix TMPDIR being set to the temp directory Nix used
784-
// in the build environment. When there's more time to test, we should
785-
// probably include all of the variables that Nix ignores:
786-
// https://github.com/NixOS/nix/blob/92611e6e4c1c5c712ca7d5f9a258640662d006df/src/nix/develop.cc#L291-L357
787-
delete(env, "TEMP")
788-
delete(env, "TEMPDIR")
789-
delete(env, "TMP")
790-
delete(env, "TMPDIR")
791-
792-
// Copy over (and overwrite) vars that we explicitly "leak", as well as DEVBOX_ vars.
793-
for _, kv := range os.Environ() {
794-
key, val, found := strings.Cut(kv, "=")
795-
if !found {
796-
return nil, errors.Errorf("expected \"=\" in keyval: %s", kv)
807+
if val.Type != "exported" {
808+
continue
797809
}
798810

799-
if strings.HasPrefix(key, "DEVBOX_") {
800-
env[key] = val
811+
// SSL_CERT_FILE is a special-case. We only ignore it if it's
812+
// set to a specific value. This emulates the behavior of
813+
// "nix develop".
814+
if key == "SSL_CERT_FILE" && val.Value.(string) == "/no-cert-file.crt" {
815+
continue
801816
}
802817

803-
if _, ok := leakedVars[key]; ok {
804-
env[key] = val
818+
// Certain variables get set to invalid values after Nix builds
819+
// the shell environment. For example, HOME=/homeless-shelter
820+
// and TMPDIR points to a missing directory. We want to ignore
821+
// those values and just use the values from the current
822+
// environment instead.
823+
if ignoreDevEnvVar[key] {
824+
continue
805825
}
806826

807-
if _, ok := leakedVarsForShell[key]; ok {
808-
env[key] = val
809-
}
827+
env[key] = val.Value.(string)
810828
}
829+
nixEnvPath := env["PATH"]
830+
debug.Log("nix environment PATH is: %s", nixEnvPath)
811831

812832
// These variables are only needed for shell, but we include them here in the computed env
813833
// for both shell and run in order to be as identical as possible.
@@ -831,22 +851,12 @@ func (d *Devbox) computeNixEnv(setFullPath bool) (map[string]string, error) {
831851
}
832852
}
833853

834-
// PATH handling.
835-
pluginVirtenvPath := d.pluginVirtenvPath() // TODO: consider removing this; not being used?
836-
nixPath := env["PATH"]
837-
hostPath := nix.CleanEnvPath(os.Getenv("PATH"), os.Getenv("NIX_PROFILES"))
854+
// TODO: consider removing this; not being used?
855+
pluginVirtenvPath := d.pluginVirtenvPath()
856+
debug.Log("plugin virtual environment PATH is: %s", pluginVirtenvPath)
838857

839-
// NOTE: for devbox shell, we need to defer the PATH setting, because a user's init file may prepend
840-
// stuff to PATH, which will then take precedence over the devbox-set PATH. Instead, we do the path
841-
// prepending in shellrc.tmpl. I chose to use the `setFullPath` variable instead of something like
842-
// `isShell` to discourage the addition of more logic that makes shell/run differ more.
843-
pathPrepend := fmt.Sprintf("%s:%s", pluginVirtenvPath, nixPath)
844-
if setFullPath {
845-
env["PATH"] = fmt.Sprintf("%s:%s", pathPrepend, hostPath)
846-
} else {
847-
env["PATH"] = hostPath
848-
env["DEVBOX_PATH_PREPEND"] = pathPrepend
849-
}
858+
env["PATH"] = nix.JoinPathLists(pluginVirtenvPath, nixEnvPath, currentEnvPath)
859+
debug.Log("computed unified environment PATH is: %s", env["PATH"])
850860

851861
return env, nil
852862
}
@@ -1025,63 +1035,43 @@ func commandExists(command string) bool {
10251035
return err == nil
10261036
}
10271037

1028-
// leakedVars contains a list of variables that, if set in the host, will be copied
1029-
// to the environment of devbox run/shell. If they're NOT set in the host, they will be set
1030-
// to an empty value.
1031-
// NOTE: we want to keep this list AS SMALL AS POSSIBLE. The longer this list, the less "pure"
1032-
// (and therefore, reproducible) devbox becomes.
1033-
// TODO: allow user to specify more vars to leak, in order to make development easier.
1034-
var leakedVars = map[string]bool{
1035-
"HOME": true, // Without this, HOME is set to /homeless-shelter and most programs fail.
1036-
1037-
// Where to write temporary files. nix print-dev-env sets these to an unwriteable path,
1038-
// so we override that here with whatever the host has set.
1039-
"TMP": true,
1040-
"TEMP": true,
1041-
"TMPDIR": true,
1042-
"TEMPDIR": true,
1038+
// ignoreCurrentEnvVar contains environment variables that Devbox should remove
1039+
// from the slice of [os.Environ] variables before sourcing them. These are
1040+
// variables that are set automatically by a new shell.
1041+
var ignoreCurrentEnvVar = map[string]bool{
1042+
// Devbox may change the working directory of the shell, so using the
1043+
// original PWD and OLDPWD would be wrong.
1044+
"PWD": true,
1045+
"OLDPWD": true,
1046+
1047+
// SHLVL is the number of nested shells. Copying it would give the
1048+
// Devbox shell the same level as the parent shell.
1049+
"SHLVL": true,
1050+
1051+
// The parent shell isn't guaranteed to be the same as the Devbox shell.
1052+
"SHELL": true,
10431053
}
10441054

1045-
var leakedVarsForShell = map[string]bool{
1046-
// POSIX
1047-
//
1048-
// Variables that are part of the POSIX standard.
1049-
"OLDPWD": true,
1050-
"PWD": true,
1051-
"TERM": true,
1052-
"TZ": true,
1053-
"USER": true,
1054-
1055-
// POSIX Locale
1056-
//
1057-
// Variables that are part of the POSIX standard which define
1058-
// the shell's locale.
1059-
"LC_ALL": true, // Sets and overrides all of the variables below.
1060-
"LANG": true, // Default to use for any of the variables below that are unset or null.
1061-
"LC_COLLATE": true, // Collation order.
1062-
"LC_CTYPE": true, // Character classification and case conversion.
1063-
"LC_MESSAGES": true, // Formats of informative and diagnostic messages and interactive responses.
1064-
"LC_MONETARY": true, // Monetary formatting.
1065-
"LC_NUMERIC": true, // Numeric, non-monetary formatting.
1066-
"LC_TIME": true, // Date and time formats.
1067-
1068-
// Common
1069-
//
1070-
// Variables that most programs agree on, but aren't strictly
1071-
// part of POSIX.
1072-
"TERM_PROGRAM": true, // Name of the terminal the shell is running in.
1073-
"TERM_PROGRAM_VERSION": true, // The version of TERM_PROGRAM.
1074-
"SHLVL": true, // The number of nested shells.
1075-
1076-
// Apple Terminal
1077-
//
1078-
// Special-cased variables that macOS's Terminal.app sets before
1079-
// launching the shell. It's not clear what exactly all of these do,
1080-
// but it seems like omitting them can cause problems.
1081-
"TERM_SESSION_ID": true,
1082-
"SHELL_SESSIONS_DISABLE": true, // Respect session save/resume setting (see /etc/zshrc_Apple_Terminal).
1083-
"SECURITYSESSIONID": true,
1084-
1085-
// SSH variables
1086-
"SSH_TTY": true, // Used by devbox telemetry logging
1055+
// ignoreDevEnvVar contains environment variables that Devbox should remove from
1056+
// the slice of [Devbox.PrintDevEnv] variables before sourcing them.
1057+
//
1058+
// This list comes directly from the "nix develop" source:
1059+
// https://github.com/NixOS/nix/blob/f08ad5bdbac02167f7d9f5e7f9bab57cf1c5f8c4/src/nix/develop.cc#L257-L275
1060+
var ignoreDevEnvVar = map[string]bool{
1061+
"BASHOPTS": true,
1062+
"HOME": true,
1063+
"NIX_BUILD_TOP": true,
1064+
"NIX_ENFORCE_PURITY": true,
1065+
"NIX_LOG_FD": true,
1066+
"NIX_REMOTE": true,
1067+
"PPID": true,
1068+
"SHELL": true,
1069+
"SHELLOPTS": true,
1070+
"TEMP": true,
1071+
"TEMPDIR": true,
1072+
"TERM": true,
1073+
"TMP": true,
1074+
"TMPDIR": true,
1075+
"TZ": true,
1076+
"UID": true,
10871077
}

0 commit comments

Comments
 (0)