@@ -163,6 +163,16 @@ connection."
163163 (and (pi-coding-agent--remote-home-path-p path)
164164 (not (pi-coding-agent--plain-remote-home-path-p path))))
165165
166+ (defun pi-coding-agent--safe-shell-remote-home-path-p (path )
167+ " Return non-nil when PATH has a portable remote-shell home prefix.
168+ Accept plain `~' and POSIX portable named-user forms such as `~root/path' .
169+ Reject shell-significant and reserved expansion forms before an unquoted tilde
170+ prefix can cross the shell-host path boundary."
171+ (and (stringp path)
172+ (string-match-p
173+ " \\ `\\ (?:~\\ |~[[:alpha:]_][[:alnum:]_.-]*\\ )\\ (?:/\\ |\\ '\\ )"
174+ path)))
175+
166176(defun pi-coding-agent--remote-prefix-for-path (path )
167177 " Return PATH's full TRAMP remote prefix, or nil."
168178 (and (stringp path)
@@ -282,6 +292,85 @@ process filters or callbacks."
282292 (substring path (length prefix))
283293 (file-local-name path)))
284294
295+ (defun pi-coding-agent--shell-command-path (path &optional anchor )
296+ " Return unquoted PATH in the shell host's file-name namespace.
297+ The shell host is selected by ANCHOR or `default-directory' : local paths stay
298+ local, while paths under a remote ANCHOR are for the remote shell started by
299+ Emacs's `shell-command' file-name handler. Relative paths become absolute,
300+ matching TRAMP prefixes (including multi-hop routes) are removed, and remote
301+ `~/' and `~USER/' forms remain unexpanded for that shell. Local home paths are
302+ expanded by Emacs. Exactly one recognized Emacs file-name quote layer is
303+ removed. Explicit remote local names must then be absolute or home-rooted;
304+ empty and relative forms are rejected rather than reinterpreted lexically.
305+
306+ This is deliberately separate from `pi-coding-agent--process-local-path' , whose
307+ outbound JSON contract is specific to Pi RPC and rejects named remote homes.
308+ This pure conversion neither checks file existence nor contacts a remote host.
309+ Malformed or incompatible remote paths signal `user-error' ."
310+ (when-let* ((emacs-path (pi-coding-agent--emacs-path path anchor)))
311+ (let* ((remote-anchor (pi-coding-agent--remote-prefix anchor))
312+ (localname (file-name-unquote
313+ (pi-coding-agent--local-name-for-process emacs-path))))
314+ (cond
315+ ((not remote-anchor)
316+ (if (file-name-absolute-p localname)
317+ localname
318+ (let ((file-name-handler-alist nil ))
319+ (expand-file-name
320+ localname
321+ (file-name-unquote (or anchor default-directory))))))
322+ ((string-prefix-p " /" localname)
323+ localname)
324+ ((pi-coding-agent--safe-shell-remote-home-path-p localname)
325+ localname)
326+ ((pi-coding-agent--remote-home-path-p localname)
327+ (user-error " Unsafe shell home prefix in path: %S" localname))
328+ (t
329+ (user-error " Remote shell path is not absolute or home-rooted: %S"
330+ localname))))))
331+
332+ (defun pi-coding-agent--shell-quote-path (path &optional anchor )
333+ " Quote shell-local PATH exactly once for a shell under ANCHOR.
334+ PATH should be the result of `pi-coding-agent--shell-command-path' . A remote
335+ `~/' or portable `~USER/' prefix is left unquoted so the remote shell can
336+ expand it; only the remaining path is POSIX-quoted. Other remote paths use
337+ POSIX quoting in full, while local paths use the platform's normal
338+ `shell-quote-argument' behavior. A terminal ampersand gets an adjacent empty
339+ quoted fragment because `shell-command' otherwise treats it as an async marker
340+ without parsing the shell escape. Unsafe home prefixes and malformed paths
341+ signal `user-error' ."
342+ (pi-coding-agent--ensure-usable-path-string path)
343+ (unless (and (stringp path) (not (string-empty-p path)))
344+ (user-error " Shell path is empty or not a string" ))
345+ (let* ((remote-anchor (pi-coding-agent--remote-prefix anchor))
346+ (quoted
347+ (if (and remote-anchor
348+ (pi-coding-agent--remote-home-path-p path))
349+ (if (string-match
350+ " \\ `\\ (~\\ |~[[:alpha:]_][[:alnum:]_.-]*\\ )\\ (?:/\\ |\\ '\\ )"
351+ path)
352+ (let* ((prefix (match-string 1 path))
353+ (prefix-end (match-end 1 ))
354+ (slash-p (and (< prefix-end (length path))
355+ (eq (aref path prefix-end) ?/ )))
356+ (rest (and slash-p
357+ (substring path (1+ prefix-end)))))
358+ (if slash-p
359+ (concat prefix " /"
360+ (if (string-empty-p rest)
361+ " "
362+ (shell-quote-argument rest t )))
363+ prefix))
364+ (user-error " Unsafe shell home prefix in path: %S" path))
365+ (shell-quote-argument path (and remote-anchor t )))))
366+ ; ; `shell-command' detects a final ampersand lexically, without honoring
367+ ; ; shell escaping. Concatenate an empty quoted fragment so a filename
368+ ; ; ending in `&' remains one operand but cannot switch execution to async.
369+ (if (string-suffix-p " &" quoted)
370+ (concat quoted
371+ (shell-quote-argument " " (and remote-anchor t )))
372+ quoted)))
373+
285374(defun pi-coding-agent--process-local-path (path &optional anchor )
286375 " Return outbound PATH as the process-local path Pi should receive.
287376PATH must be a nonempty string; otherwise return nil. Emacs/TRAMP paths are
0 commit comments