tailscale/tailscale · error
invalid --env-fd %d: must be >= 3
Error message
invalid --env-fd %d: must be >= 3
What it means
be-child, tailscaled's privileged SSH session helper, parses the flags its parent passed. --env-fd names the descriptor, installed via exec.Cmd.ExtraFiles, that holds the forwarded environment as a JSON array. Because fd 0/1/2 are always stdin/stdout/stderr, an --env-fd of 0, 1, or 2 can never reference the ExtraFiles payload, so parseIncubatorArgs rejects it immediately.
Source
Thrown at ssh/tailssh/incubator_plan9.go:207
flags.StringVar(&ia.localUser, "local-user", "", "the user to run as")
flags.StringVar(&ia.homeDir, "home-dir", "/", "the user's home directory")
flags.StringVar(&ia.remoteUser, "remote-user", "", "the remote user/tags")
flags.StringVar(&ia.remoteIP, "remote-ip", "", "the remote Tailscale IP")
flags.StringVar(&ia.ttyName, "tty-name", "", "the tty name (pts/3)")
flags.BoolVar(&ia.hasTTY, "has-tty", false, "is the output attached to a tty")
flags.StringVar(&ia.cmd, "cmd", "", "the cmd to launch, including all arguments (ignored in sftp mode)")
flags.BoolVar(&ia.isShell, "shell", false, "is launching a shell (with no cmds)")
flags.BoolVar(&ia.isSFTP, "sftp", false, "run sftp server (cmd is ignored)")
flags.BoolVar(&ia.forceV1Behavior, "force-v1-behavior", false, "allow falling back to the su command if login is unavailable")
flags.BoolVar(&ia.debugTest, "debug-test", false, "should debug in test mode")
flags.BoolVar(&ia.isSELinuxEnforcing, "is-selinux-enforcing", false, "whether SELinux is in enforcing mode")
// DEPRECATED: retained for version-skew compatibility only. DO NOT USE.
flags.StringVar(&ia.encodedEnv, "encoded-env", "", "deprecated; do not use")
flags.IntVar(&ia.envFD, "env-fd", -1, "file descriptor to read the forwarded environment from (JSON array of KEY=VALUE pairs)")
flags.Parse(args)
// envFD comes from an ExtraFiles entry, so it must never name stdin/out/err
if ia.envFD >= 0 && ia.envFD < 3 {
return ia, fmt.Errorf("invalid --env-fd %d: must be >= 3", ia.envFD)
}
return ia, nil
}
// loadForwardedEnv reads the client-forwarded environment pairs into ia.forwardedEnv, from the
// inherited file named by --env-fd. The pairs only enter the su/login/shell environment,
// never this process's own environment.
func (ia *incubatorArgs) loadForwardedEnv() error {
var pairs []string
switch {
case ia.envFD >= 0:
if ia.envFD < 3 {
return fmt.Errorf("invalid --env-fd=%d: must be >= 3", ia.envFD)
}
f := os.NewFile(uintptr(ia.envFD), "forwarded-env")
defer f.Close()
if err := json.NewDecoder(f).Decode(&pairs); err != nil {
return fmt.Errorf("unable to read forwarded environment: %w", err)View on GitHub (pinned to 6e0912f979)
Solutions
- Pass the fd that actually maps to the ExtraFiles payload - the first entry is 3
- Let tailscaled construct the be-child command line instead of hand-building it
- Upgrade so parent and helper binaries come from the same release
- Drop --env-fd entirely (default -1) when environment forwarding is not needed
Example fix
// before
args := []string{"--groups=1000", "--env-fd=1"} // 1 is stderr
cmd := exec.Command(beChild, args...)
// after
envFile := writeEnvPayload(pairs) // temp *os.File with the JSON array
cmd := exec.Command(beChild, "--groups=1000", "--env-fd=3")
cmd.ExtraFiles = []*os.File{envFile} // index 0 => fd 3 in the child Defensive patterns
Strategy: validation
Validate before calling
// Derive --env-fd from the ExtraFiles index instead of hardcoding
fd := 3 + len(cmd.ExtraFiles) // ExtraFiles[i] becomes fd 3+i in the child
args = append(args, fmt.Sprintf("--env-fd=%d", fd)) Type guard
func validEnvFD(fd int) bool { return fd < 0 || fd >= 3 } // -1 = unset; otherwise must clear stdin/out/err Prevention
- Never point --env-fd at 0, 1, or 2; those are stdio
- Compute the fd as 3 + index of the payload file in ExtraFiles
- Keep tailscaled and be-child from the same build so the flag contract matches
When it happens
Trigger: Invoking be-child with --env-fd=0, --env-fd=1, or --env-fd=2 (hand-written command lines, tests), or a mismatched tailscaled parent computing the descriptor number wrongly - ExtraFiles entry i is fd 3+i in the child (the tests expect --env-fd=3).
Common situations: Manual testing of be-child with hand-built flag lists; partial upgrades mixing tailscaled and helper binary versions; wrapper scripts that reopen or reorder the standard descriptors.
Related errors
- invalid --env-fd=%d: must be >= 3
- unable to read forwarded environment: %w
- --sftp and --shell are mutually exclusive
- unable to parse encodedEnv %q: %w
- writing /net/ndb: %w
AI-assisted analysis of tailscale/tailscale@6e0912f979 (2026-08-18).
Data as JSON: /api/errors/2ad9805c9e2035d5.
Report an issue: GitHub.