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

  1. Pass the fd that actually maps to the ExtraFiles payload - the first entry is 3
  2. Let tailscaled construct the be-child command line instead of hand-building it
  3. Upgrade so parent and helper binaries come from the same release
  4. 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

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


AI-assisted analysis of tailscale/tailscale@6e0912f979 (2026-08-18). Data as JSON: /api/errors/2ad9805c9e2035d5. Report an issue: GitHub.