{"record":{"id":"2ad9805c9e2035d5","repo":"tailscale/tailscale","slug":"invalid-env-fd-d-must-be-3-2ad980","errorCode":null,"errorMessage":"invalid --env-fd %d: must be >= 3","messagePattern":"invalid --env-fd (.+?): must be >= 3","errorType":"console","errorClass":null,"httpStatus":null,"severity":"error","filePath":"ssh/tailssh/incubator_plan9.go","lineNumber":207,"sourceCode":"\tflags.StringVar(&ia.localUser, \"local-user\", \"\", \"the user to run as\")\n\tflags.StringVar(&ia.homeDir, \"home-dir\", \"/\", \"the user's home directory\")\n\tflags.StringVar(&ia.remoteUser, \"remote-user\", \"\", \"the remote user/tags\")\n\tflags.StringVar(&ia.remoteIP, \"remote-ip\", \"\", \"the remote Tailscale IP\")\n\tflags.StringVar(&ia.ttyName, \"tty-name\", \"\", \"the tty name (pts/3)\")\n\tflags.BoolVar(&ia.hasTTY, \"has-tty\", false, \"is the output attached to a tty\")\n\tflags.StringVar(&ia.cmd, \"cmd\", \"\", \"the cmd to launch, including all arguments (ignored in sftp mode)\")\n\tflags.BoolVar(&ia.isShell, \"shell\", false, \"is launching a shell (with no cmds)\")\n\tflags.BoolVar(&ia.isSFTP, \"sftp\", false, \"run sftp server (cmd is ignored)\")\n\tflags.BoolVar(&ia.forceV1Behavior, \"force-v1-behavior\", false, \"allow falling back to the su command if login is unavailable\")\n\tflags.BoolVar(&ia.debugTest, \"debug-test\", false, \"should debug in test mode\")\n\tflags.BoolVar(&ia.isSELinuxEnforcing, \"is-selinux-enforcing\", false, \"whether SELinux is in enforcing mode\")\n\t// DEPRECATED: retained for version-skew compatibility only. DO NOT USE.\n\tflags.StringVar(&ia.encodedEnv, \"encoded-env\", \"\", \"deprecated; do not use\")\n\tflags.IntVar(&ia.envFD, \"env-fd\", -1, \"file descriptor to read the forwarded environment from (JSON array of KEY=VALUE pairs)\")\n\tflags.Parse(args)\n\t// envFD comes from an ExtraFiles entry, so it must never name stdin/out/err\n\tif ia.envFD >= 0 && ia.envFD < 3 {\n\t\treturn ia, fmt.Errorf(\"invalid --env-fd %d: must be >= 3\", ia.envFD)\n\t}\n\treturn ia, nil\n}\n\n// loadForwardedEnv reads the client-forwarded environment pairs into ia.forwardedEnv, from the\n// inherited file named by --env-fd. The pairs only enter the su/login/shell environment,\n// never this process's own environment.\nfunc (ia *incubatorArgs) loadForwardedEnv() error {\n\tvar pairs []string\n\tswitch {\n\tcase ia.envFD >= 0:\n\t\tif ia.envFD < 3 {\n\t\t\treturn fmt.Errorf(\"invalid --env-fd=%d: must be >= 3\", ia.envFD)\n\t\t}\n\t\tf := os.NewFile(uintptr(ia.envFD), \"forwarded-env\")\n\t\tdefer f.Close()\n\t\tif err := json.NewDecoder(f).Decode(&pairs); err != nil {\n\t\t\treturn fmt.Errorf(\"unable to read forwarded environment: %w\", err)","sourceCodeStart":189,"sourceCodeEnd":225,"githubUrl":"https://github.com/tailscale/tailscale/blob/6e0912f97994f927632b34ae9e63b53d6516a6ac/ssh/tailssh/incubator_plan9.go#L189-L225","documentation":"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.","triggerScenarios":"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).","commonSituations":"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.","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"],"exampleFix":"// before\nargs := []string{\"--groups=1000\", \"--env-fd=1\"} // 1 is stderr\ncmd := exec.Command(beChild, args...)\n\n// after\nenvFile := writeEnvPayload(pairs) // temp *os.File with the JSON array\ncmd := exec.Command(beChild, \"--groups=1000\", \"--env-fd=3\")\ncmd.ExtraFiles = []*os.File{envFile} // index 0 => fd 3 in the child","handlingStrategy":"validation","validationCode":"// Derive --env-fd from the ExtraFiles index instead of hardcoding\nfd := 3 + len(cmd.ExtraFiles) // ExtraFiles[i] becomes fd 3+i in the child\nargs = append(args, fmt.Sprintf(\"--env-fd=%d\", fd))","typeGuard":"func validEnvFD(fd int) bool { return fd < 0 || fd >= 3 } // -1 = unset; otherwise must clear stdin/out/err","tryCatchPattern":null,"preventionTips":["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"],"tags":["ssh","incubator","file-descriptor","process-exec","plan9"],"backgroundTag":"invalid-file-descriptor","analyzedSha":"6e0912f97994f927632b34ae9e63b53d6516a6ac","analyzedAt":"2026-08-18T08:17:25.280Z","contentChangedAt":"2026-08-18T08:17:25.280Z","schemaVersion":2},"datasetVersion":"2026-09-14T05:17:10.506Z"}