99 "fmt"
1010 "os"
1111 "os/exec"
12+ "path/filepath"
1213 "runtime"
1314 "strconv"
1415 "strings"
@@ -28,6 +29,7 @@ import (
2829 "github.com/lima-vm/lima/v2/pkg/networks/reconcile"
2930 "github.com/lima-vm/lima/v2/pkg/sshutil"
3031 "github.com/lima-vm/lima/v2/pkg/store"
32+ "github.com/lima-vm/lima/v2/pkg/uiutil"
3133)
3234
3335const shellHelp = `Execute shell in Lima
@@ -64,9 +66,15 @@ func newShellCommand() *cobra.Command {
6466 shellCmd .Flags ().Bool ("reconnect" , false , "Reconnect to the SSH session" )
6567 shellCmd .Flags ().Bool ("preserve-env" , false , "Propagate environment variables to the shell" )
6668 shellCmd .Flags ().Bool ("start" , false , "Start the instance if it is not already running" )
69+ shellCmd .Flags ().Bool ("sync-host-workdir" , false , "Copy the host working directory to the guest to run AI commands inside VMs (prevents AI agents from breaking the host files)" )
6770 return shellCmd
6871}
6972
73+ const (
74+ rsyncMinimumSrcDirDepth = 4 // Depth of "/Users/USER" is 3.
75+ guestSyncedWorkdir = "~/synced-workdir"
76+ )
77+
7078func shellAction (cmd * cobra.Command , args []string ) error {
7179 ctx := cmd .Context ()
7280 flags := cmd .Flags ()
@@ -150,29 +158,45 @@ func shellAction(cmd *cobra.Command, args []string) error {
150158 }
151159 }
152160
161+ syncHostWorkdir , err := flags .GetBool ("sync-host-workdir" )
162+ if err != nil {
163+ return fmt .Errorf ("failed to get sync-host-workdir flag: %w" , err )
164+ } else if syncHostWorkdir && len (inst .Config .Mounts ) > 0 {
165+ return errors .New ("cannot use `--sync-host-workdir` when the instance has host mounts configured, use `--mount-none` to disable mounts" )
166+ }
167+
153168 // When workDir is explicitly set, the shell MUST have workDir as the cwd, or exit with an error.
154169 //
155170 // changeDirCmd := "cd workDir || exit 1" if workDir != ""
156171 // := "cd hostCurrentDir || cd hostHomeDir" if workDir == ""
157172 var changeDirCmd string
173+ hostCurrentDir , err := hostCurrentDirectory (ctx , inst )
174+ if err != nil {
175+ changeDirCmd = "false"
176+ logrus .WithError (err ).Warn ("failed to get the current directory" )
177+ }
178+ if syncHostWorkdir {
179+ if _ , err := exec .LookPath ("rsync" ); err != nil {
180+ return fmt .Errorf ("rsync is required for `--sync-host-workdir` but not found: %w" , err )
181+ }
182+
183+ srcWdDepth := len (strings .Split (hostCurrentDir , string (os .PathSeparator )))
184+ if srcWdDepth < rsyncMinimumSrcDirDepth {
185+ return fmt .Errorf ("expected the depth of the host working directory (%q) to be more than %d, only got %d (Hint: %s)" ,
186+ hostCurrentDir , rsyncMinimumSrcDirDepth , srcWdDepth , "cd to a deeper directory" )
187+ }
188+ }
189+
158190 workDir , err := cmd .Flags ().GetString ("workdir" )
159191 if err != nil {
160192 return err
161193 }
162- if workDir != "" {
194+ switch {
195+ case workDir != "" :
163196 changeDirCmd = fmt .Sprintf ("cd %s || exit 1" , shellescape .Quote (workDir ))
164197 // FIXME: check whether y.Mounts contains the home, not just len > 0
165- } else if len (inst .Config .Mounts ) > 0 || inst .VMType == limatype .WSL2 {
166- hostCurrentDir , err := os .Getwd ()
167- if err == nil && runtime .GOOS == "windows" {
168- hostCurrentDir , err = mountDirFromWindowsDir (ctx , inst , hostCurrentDir )
169- }
170- if err == nil {
171- changeDirCmd = fmt .Sprintf ("cd %s" , shellescape .Quote (hostCurrentDir ))
172- } else {
173- changeDirCmd = "false"
174- logrus .WithError (err ).Warn ("failed to get the current directory" )
175- }
198+ case len (inst .Config .Mounts ) > 0 || inst .VMType == limatype .WSL2 :
199+ changeDirCmd = fmt .Sprintf ("cd %s" , shellescape .Quote (hostCurrentDir ))
176200 hostHomeDir , err := os .UserHomeDir ()
177201 if err == nil && runtime .GOOS == "windows" {
178202 hostHomeDir , err = mountDirFromWindowsDir (ctx , inst , hostHomeDir )
@@ -182,7 +206,9 @@ func shellAction(cmd *cobra.Command, args []string) error {
182206 } else {
183207 logrus .WithError (err ).Warn ("failed to get the home directory" )
184208 }
185- } else {
209+ case syncHostWorkdir :
210+ changeDirCmd = fmt .Sprintf ("cd %s/%s" , guestSyncedWorkdir , filepath .Base (hostCurrentDir ))
211+ default :
186212 logrus .Debug ("the host home does not seem mounted, so the guest shell will have a different cwd" )
187213 }
188214
@@ -267,6 +293,17 @@ func shellAction(cmd *cobra.Command, args []string) error {
267293 }
268294 sshArgs := append ([]string {}, sshExe .Args ... )
269295 sshArgs = append (sshArgs , sshutil .SSHArgsFromOpts (sshOpts )... )
296+
297+ var sshExecForRsync * exec.Cmd
298+ if syncHostWorkdir {
299+ logrus .Infof ("Syncing host current directory(%s) to guest instance..." , hostCurrentDir )
300+ sshExecForRsync = exec .CommandContext (ctx , sshExe .Exe , sshArgs ... )
301+ if err := rsyncDirectory (ctx , sshExecForRsync , hostCurrentDir , fmt .Sprintf ("%s:%s" , * inst .Config .User .Name + "@" + inst .SSHAddress , guestSyncedWorkdir )); err != nil {
302+ return fmt .Errorf ("failed to sync host working directory to guest instance: %w" , err )
303+ }
304+ logrus .Infof ("Successfully synced host current directory to guest(%s/%s) instance." , guestSyncedWorkdir , filepath .Base (hostCurrentDir ))
305+ }
306+
270307 if isatty .IsTerminal (os .Stdout .Fd ()) || isatty .IsCygwinTerminal (os .Stdout .Fd ()) {
271308 // required for showing the shell prompt: https://stackoverflow.com/a/626574
272309 sshArgs = append (sshArgs , "-t" )
@@ -296,7 +333,138 @@ func shellAction(cmd *cobra.Command, args []string) error {
296333 logrus .Debugf ("executing ssh (may take a long)): %+v" , sshCmd .Args )
297334
298335 // TODO: use syscall.Exec directly (results in losing tty?)
299- return sshCmd .Run ()
336+ if err := sshCmd .Run (); err != nil {
337+ return err
338+ }
339+
340+ // Once the shell command finishes, rsync back the changes from guest workdir
341+ // to the host and delete the guest synced workdir only if the user
342+ // confirms the changes.
343+ if syncHostWorkdir {
344+ askUserForRsyncBack (ctx , inst , sshExecForRsync , hostCurrentDir )
345+ }
346+ return nil
347+ }
348+
349+ func askUserForRsyncBack (ctx context.Context , inst * limatype.Instance , sshCmd * exec.Cmd , hostCurrentDir string ) {
350+ message := "⚠️ Accept the changes?"
351+ options := []string {
352+ "Yes" ,
353+ "No" ,
354+ "View the changed contents" ,
355+ }
356+
357+ remoteSource := fmt .Sprintf ("%s:%s/%s" , * inst .Config .User .Name + "@" + inst .SSHAddress , guestSyncedWorkdir , filepath .Base (hostCurrentDir ))
358+ hostTmpDest , err := os .MkdirTemp ("" , "lima-guest-synced-*" )
359+ if err != nil {
360+ logrus .WithError (err ).Warn ("Failed to create temporary directory" )
361+ return
362+ }
363+ defer func () {
364+ if err := os .RemoveAll (hostTmpDest ); err != nil {
365+ logrus .WithError (err ).Warnf ("Failed to clean up temporary directory %s" , hostTmpDest )
366+ }
367+ }()
368+
369+ if err := rsyncDirectory (ctx , sshCmd , remoteSource , hostTmpDest ); err != nil {
370+ logrus .WithError (err ).Warn ("Failed to sync back the changes to host for viewing" )
371+ return
372+ }
373+
374+ for {
375+ ans , err := uiutil .Select (message , options )
376+ if err != nil {
377+ if errors .Is (err , uiutil .InterruptErr ) {
378+ logrus .Fatal ("Interrupted by user" )
379+ }
380+ logrus .WithError (err ).Warn ("Failed to open TUI" )
381+ return
382+ }
383+
384+ switch ans {
385+ case 0 : // Yes
386+ dest := filepath .Dir (hostCurrentDir )
387+ if err := rsyncDirectory (ctx , sshCmd , remoteSource , dest ); err != nil {
388+ logrus .WithError (err ).Warn ("Failed to sync back the changes to host" )
389+ return
390+ }
391+ cleanGuestSyncedWorkdir (ctx , sshCmd )
392+ logrus .Info ("Successfully synced back the changes to host." )
393+ return
394+ case 1 : // No
395+ cleanGuestSyncedWorkdir (ctx , sshCmd )
396+ logrus .Info ("Skipping syncing back the changes to host." )
397+ return
398+ case 2 : // View the changed contents
399+ diffCmd := exec .CommandContext (ctx , "diff" , "-ru" , "--color=always" , hostCurrentDir , filepath .Join (hostTmpDest , filepath .Base (hostCurrentDir )))
400+ lessCmd := exec .CommandContext (ctx , "less" , "-R" )
401+ pipeIn , err := lessCmd .StdinPipe ()
402+ if err != nil {
403+ logrus .WithError (err ).Warn ("Failed to get less stdin" )
404+ return
405+ }
406+ diffCmd .Stdout = pipeIn
407+ lessCmd .Stdout = os .Stdout
408+ lessCmd .Stderr = os .Stderr
409+
410+ if err := lessCmd .Start (); err != nil {
411+ logrus .WithError (err ).Warn ("Failed to start less" )
412+ return
413+ }
414+ if err := diffCmd .Run (); err != nil {
415+ // Command `diff` returns exit code 1 when files differ.
416+ var exitErr * exec.ExitError
417+ if errors .As (err , & exitErr ) && exitErr .ExitCode () >= 2 {
418+ logrus .WithError (err ).Warn ("Failed to run diff" )
419+ _ = pipeIn .Close ()
420+ return
421+ }
422+ }
423+
424+ _ = pipeIn .Close ()
425+
426+ if err := lessCmd .Wait (); err != nil {
427+ logrus .WithError (err ).Warn ("Failed to wait for less" )
428+ return
429+ }
430+ }
431+ }
432+ }
433+
434+ func cleanGuestSyncedWorkdir (ctx context.Context , sshCmd * exec.Cmd ) {
435+ sshCmd .Args = append (sshCmd .Args , "rm" , "-rf" , guestSyncedWorkdir )
436+ sshRmCmd := exec .CommandContext (ctx , sshCmd .Path , sshCmd .Args ... )
437+ if err := sshRmCmd .Run (); err != nil {
438+ logrus .WithError (err ).Warn ("Failed to clean up guest synced workdir" )
439+ return
440+ }
441+ logrus .Debug ("Successfully cleaned up guest synced workdir." )
442+ }
443+
444+ func hostCurrentDirectory (ctx context.Context , inst * limatype.Instance ) (string , error ) {
445+ hostCurrentDir , err := os .Getwd ()
446+ if err == nil && runtime .GOOS == "windows" {
447+ hostCurrentDir , err = mountDirFromWindowsDir (ctx , inst , hostCurrentDir )
448+ }
449+ return hostCurrentDir , err
450+ }
451+
452+ // Syncs a directory from host to guest and vice-versa. It creates a directory
453+ // named "synced-workdir" in the guest's home directory and copies the contents
454+ // of the host's current working directory into it.
455+ func rsyncDirectory (ctx context.Context , sshCmd * exec.Cmd , source , destination string ) error {
456+ rsyncArgs := []string {
457+ "-ah" ,
458+ "-e" , sshCmd .String (),
459+ source ,
460+ destination ,
461+ }
462+ rsyncCmd := exec .CommandContext (ctx , "rsync" , rsyncArgs ... )
463+ rsyncCmd .Stdin = os .Stdin
464+ rsyncCmd .Stdout = os .Stdout
465+ rsyncCmd .Stderr = os .Stderr
466+ logrus .Debugf ("executing rsync: %+v" , rsyncCmd .Args )
467+ return rsyncCmd .Run ()
300468}
301469
302470func mountDirFromWindowsDir (ctx context.Context , inst * limatype.Instance , dir string ) (string , error ) {
0 commit comments