Subject: bug#62326: tramp-container PATH logic is incorrect

From: ParetoOptimalDev <[email-masked]>
Date: Mon, 20 Mar 2023 16:58:22 -0500

For docker containers tramp should set the PATH equal to whatever `ENV
PATH=...` is in the Dockerfile. To be precise it should match the output of:

```
docker inspect --format='{{json .Config.Env}} $docker_container_id
```

Right now however, there is a piece of logic in tramp-get-remote-path
that removes non-existent directories:

(delq
 nil
 (mapcar
  (lambda (x)
    (and
     (stringp x)
     (file-directory-p (tramp-make-tramp-file-name vec x))
     x))
  remote-path))

This seems reasonable, but in practice it doesn't seem to work. Here is
a script to demonstrate with the haskell container from dockerhub:

(require 'tramp)

;; pre-require things that were making loading messages that clutter up
the batch output
(require 'em-alias)
(require 'em-banner)
(require 'em-basic)
(require 'em-cmpl)
(require 'em-extpipe)
(require 'em-glob)
(require 'em-hist)
(require 'em-ls)
(require 'em-pred)
(require 'em-prompt)
(require 'em-script)
(require 'em-term)
(require 'em-unix)

(setq edebug-print-length 500)
(setq tramp-verbose 7)

(setq existing-haskell-container-name "nifty_davinci")
(defun start-new-haskell-container ()
  """ returns container id"""
  (string-trim (shell-command-to-string "docker run -i --rm --detach haskell:slim@sha256:68280eb4fd3d4ee1b62cf40619fd6e956a741f693ef53e9dd4168d2c721880f5")))
(setq docker-container-id
      (or existing-haskell-container-name (start-new-haskell-container)))
(message (format "emacs-version: %s" emacs-version))
(message (format "tramp-version: %s" tramp-version))
(message "")
(message "using the docker haskell image")
(message "expected path is:")
(message (string-trim (shell-command-to-string (format "docker inspect --format='{{json .Config.Env}}' %s" docker-container-id))))

(let ((default-directory (concat "/docker:" docker-container-id ":"))
      (tramp-remote-path '(tramp-own-remote-path)))
  (tramp-cleanup-all-connections)
  (message (concat "path is: " (truncate-string-to-width (eshell-command-result "echo $PATH") 100))))

Here is what happens when I run the code above:

$ emacs -Q --batch --load config.el
emacs-version: 30.0.50
tramp-version: 2.7.0-pre

using the docker haskell image
expected path is:
["PATH=/root/.cabal/bin:/root/.local/bin:/opt/ghc/9.4.4/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin","LANG=C.UTF-8"]
Tramp: Sending command ‘exec docker exec -it a896e8aacaf0adaedd49b9bcd6b8104d7b5c4343d08382b4912ffaa8192bbb81 /bin/sh  -i’
Tramp: Found remote shell prompt on ‘a896e8aacaf0adaedd49b9bcd6b8104d7b5c4343d08382b4912ffaa8192bbb81’
path is: /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

You can see that crucial directories containing the Haskell compiler and
build tool are missing.

I'm not sure why they don't exist. But I can verify If I remove the
logic and preserve the non-existent directories things work:

(defun my/tramp-get-remote-path (vec)
  "Compile list of remote directories for PATH.
Nonexistent directories are removed from spec."
  (with-current-buffer (tramp-get-connection-buffer vec)
    ;; Expand connection-local variables.
    (tramp-set-connection-local-variables vec)
    (with-tramp-connection-property
	;; When `tramp-own-remote-path' is in `tramp-remote-path', we
	;; cache the result for the session only.  Otherwise, the
	;; result is cached persistently.
	(if (memq 'tramp-own-remote-path tramp-remote-path)
	    (tramp-get-process vec) vec)
	"remote-path"
      (let* ((remote-path (copy-tree tramp-remote-path))
	     (elt1 (memq 'tramp-default-remote-path remote-path))
	     (elt2 (memq 'tramp-own-remote-path remote-path))
	     (default-remote-path
	      (when elt1
		(or
		 (tramp-send-command-and-read
		  vec
                  (format
                   "echo \\\"`getconf PATH 2>%s`\\\""
                   (tramp-get-remote-null-device vec))
                  'noerror)
		 ;; Default if "getconf" is not available.
		 (progn
		   (tramp-message
		    vec 3
		    "`getconf PATH' not successful, using default value \"%s\"."
		    "/bin:/usr/bin")
		   "/bin:/usr/bin"))))
	     (own-remote-path
	      ;; The login shell could return more than just the $PATH
	      ;; string.  So we use `tramp-end-of-heredoc' as marker.
	      (when elt2
		(or
		 (tramp-send-command-and-read
		  vec
		  (format
		   "%s %s %s 'echo %s \\\"$PATH\\\"'"
		   (tramp-get-method-parameter vec 'tramp-remote-shell)
		   (string-join
		    (tramp-get-method-parameter vec 'tramp-remote-shell-login)
		    " ")
		   (string-join
		    (tramp-get-method-parameter vec 'tramp-remote-shell-args)
		    " ")
		   (tramp-shell-quote-argument tramp-end-of-heredoc))
		  'noerror (rx (literal tramp-end-of-heredoc)))
		 (progn
		   (tramp-message
		    vec 2 "Could not retrieve `tramp-own-remote-path'")
		   nil)))))

	;; Replace place holder `tramp-default-remote-path'.
	(when elt1
	  (setcdr elt1
		  (append
                   (split-string (or default-remote-path "") ":" 'omit)
		   (cdr elt1)))
	  (setq remote-path (delq 'tramp-default-remote-path remote-path)))

	;; Replace place holder `tramp-own-remote-path'.
	(when elt2
	  (setcdr elt2
		  (append
                   (split-string (or own-remote-path "") ":" 'omit)
		   (cdr elt2)))
	  (setq remote-path (delq 'tramp-own-remote-path remote-path)))

	;; Remove double entries.
	(setq elt1 remote-path)
	(while (consp elt1)
	  (while (and (car elt1) (setq elt2 (member (car elt1) (cdr elt1))))
	    (setcar elt2 nil))
	  (setq elt1 (cdr elt1)))

	(delq
	 nil
	 remote-path)))))

(advice-add 'tramp-get-remote-path :override 'my/tramp-get-remote-path)

I'm not sure if `Remove non-existing directories.` exists for a
particular reason or not, but I'd argue it's not appropriate for docker
containers.

I'm surprised that there aren't more bug reports along these lines since
this should affect anyone working with tramp, eshell, and eglot or
lsp-mode.

Thanks