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,11 +158,35 @@ 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 fmt .Errorf ("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
@@ -163,16 +195,7 @@ func shellAction(cmd *cobra.Command, args []string) error {
163195 changeDirCmd = fmt .Sprintf ("cd %s || exit 1" , shellescape .Quote (workDir ))
164196 // FIXME: check whether y.Mounts contains the home, not just len > 0
165197 } 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+ changeDirCmd = fmt .Sprintf ("cd %s" , shellescape .Quote (hostCurrentDir ))
176199 hostHomeDir , err := os .UserHomeDir ()
177200 if err == nil && runtime .GOOS == "windows" {
178201 hostHomeDir , err = mountDirFromWindowsDir (ctx , inst , hostHomeDir )
@@ -182,6 +205,8 @@ func shellAction(cmd *cobra.Command, args []string) error {
182205 } else {
183206 logrus .WithError (err ).Warn ("failed to get the home directory" )
184207 }
208+ } else if syncHostWorkdir {
209+ changeDirCmd = fmt .Sprintf ("cd %s/%s" , guestSyncedWorkdir , filepath .Base (hostCurrentDir ))
185210 } else {
186211 logrus .Debug ("the host home does not seem mounted, so the guest shell will have a different cwd" )
187212 }
@@ -267,6 +292,17 @@ func shellAction(cmd *cobra.Command, args []string) error {
267292 }
268293 sshArgs := append ([]string {}, sshExe .Args ... )
269294 sshArgs = append (sshArgs , sshutil .SSHArgsFromOpts (sshOpts )... )
295+
296+ var sshExecForRsync * exec.Cmd
297+ if syncHostWorkdir {
298+ logrus .Infof ("Syncing host current directory(%s) to guest instance..." , hostCurrentDir )
299+ sshExecForRsync = exec .CommandContext (ctx , sshExe .Exe , sshArgs ... )
300+ if err := rsyncDirectory (ctx , sshExecForRsync , hostCurrentDir , fmt .Sprintf ("%s:%s" , * inst .Config .User .Name + "@" + inst .SSHAddress , guestSyncedWorkdir )); err != nil {
301+ return fmt .Errorf ("failed to sync host working directory to guest instance: %w" , err )
302+ }
303+ logrus .Infof ("Successfully synced host current directory to guest(%s/%s) instance." , guestSyncedWorkdir , filepath .Base (hostCurrentDir ))
304+ }
305+
270306 if isatty .IsTerminal (os .Stdout .Fd ()) || isatty .IsCygwinTerminal (os .Stdout .Fd ()) {
271307 // required for showing the shell prompt: https://stackoverflow.com/a/626574
272308 sshArgs = append (sshArgs , "-t" )
@@ -296,7 +332,138 @@ func shellAction(cmd *cobra.Command, args []string) error {
296332 logrus .Debugf ("executing ssh (may take a long)): %+v" , sshCmd .Args )
297333
298334 // TODO: use syscall.Exec directly (results in losing tty?)
299- return sshCmd .Run ()
335+ if err := sshCmd .Run (); err != nil {
336+ return err
337+ }
338+
339+ // Once the shell command finishes, rsync back the changes from guest workdir
340+ // to the host and delete the guest synced workdir only if the user
341+ // confirms the changes.
342+ if syncHostWorkdir {
343+ askUserForRsyncBack (ctx , inst , sshExecForRsync , hostCurrentDir )
344+ }
345+ return nil
346+ }
347+
348+ func askUserForRsyncBack (ctx context.Context , inst * limatype.Instance , sshCmd * exec.Cmd , hostCurrentDir string ) {
349+ message := "⚠️ Accept the changes?"
350+ options := []string {
351+ "Yes" ,
352+ "No" ,
353+ "View the changed contents" ,
354+ }
355+
356+ remoteSource := fmt .Sprintf ("%s:%s/%s" , * inst .Config .User .Name + "@" + inst .SSHAddress , guestSyncedWorkdir , filepath .Base (hostCurrentDir ))
357+ hostTmpDest , err := os .MkdirTemp ("" , "lima-guest-synced-*" )
358+ if err != nil {
359+ logrus .WithError (err ).Warn ("Failed to create temporary directory" )
360+ return
361+ }
362+ defer func () {
363+ if err := os .RemoveAll (hostTmpDest ); err != nil {
364+ logrus .WithError (err ).Warnf ("Failed to clean up temporary directory %s" , hostTmpDest )
365+ }
366+ }()
367+
368+ if err := rsyncDirectory (ctx , sshCmd , remoteSource , hostTmpDest ); err != nil {
369+ logrus .WithError (err ).Warn ("Failed to sync back the changes to host for viewing" )
370+ return
371+ }
372+
373+ for {
374+ ans , err := uiutil .Select (message , options )
375+ if err != nil {
376+ if errors .Is (err , uiutil .InterruptErr ) {
377+ logrus .Fatal ("Interrupted by user" )
378+ }
379+ logrus .WithError (err ).Warn ("Failed to open TUI" )
380+ return
381+ }
382+
383+ switch ans {
384+ case 0 : // Yes
385+ dest := filepath .Dir (hostCurrentDir )
386+ if err := rsyncDirectory (ctx , sshCmd , remoteSource , dest ); err != nil {
387+ logrus .WithError (err ).Warn ("Failed to sync back the changes to host" )
388+ return
389+ }
390+ cleanGuestSyncedWorkdir (ctx , sshCmd )
391+ logrus .Info ("Successfully synced back the changes to host." )
392+ return
393+ case 1 : // No
394+ cleanGuestSyncedWorkdir (ctx , sshCmd )
395+ logrus .Info ("Skipping syncing back the changes to host." )
396+ return
397+ case 2 : // View the changed contents
398+ diffCmd := exec .CommandContext (ctx , "diff" , "-ru" , "--color=always" , hostCurrentDir , filepath .Join (hostTmpDest , filepath .Base (hostCurrentDir )))
399+ lessCmd := exec .CommandContext (ctx , "less" , "-R" )
400+ pipeIn , err := lessCmd .StdinPipe ()
401+ if err != nil {
402+ logrus .WithError (err ).Warn ("Failed to get less stdin" )
403+ return
404+ }
405+ diffCmd .Stdout = pipeIn
406+ lessCmd .Stdout = os .Stdout
407+ lessCmd .Stderr = os .Stderr
408+
409+ if err := lessCmd .Start (); err != nil {
410+ logrus .WithError (err ).Warn ("Failed to start less" )
411+ return
412+ }
413+ if err := diffCmd .Run (); err != nil {
414+ // Command `diff` returns exit code 1 when files differ.
415+ var exitErr * exec.ExitError
416+ if errors .As (err , & exitErr ) && exitErr .ExitCode () >= 2 {
417+ logrus .WithError (err ).Warn ("Failed to run diff" )
418+ _ = pipeIn .Close ()
419+ return
420+ }
421+ }
422+
423+ _ = pipeIn .Close ()
424+
425+ if err := lessCmd .Wait (); err != nil {
426+ logrus .WithError (err ).Warn ("Failed to wait for less" )
427+ return
428+ }
429+ }
430+ }
431+ }
432+
433+ func cleanGuestSyncedWorkdir (ctx context.Context , sshCmd * exec.Cmd ) {
434+ rmArgs := append (sshCmd .Args , "rm" , "-rf" , guestSyncedWorkdir )
435+ sshRmCmd := exec .CommandContext (ctx , sshCmd .Path , rmArgs ... )
436+ if err := sshRmCmd .Run (); err != nil {
437+ logrus .WithError (err ).Warn ("Failed to clean up guest synced workdir" )
438+ return
439+ }
440+ logrus .Debug ("Successfully cleaned up guest synced workdir." )
441+ }
442+
443+ func hostCurrentDirectory (ctx context.Context , inst * limatype.Instance ) (string , error ) {
444+ hostCurrentDir , err := os .Getwd ()
445+ if err == nil && runtime .GOOS == "windows" {
446+ hostCurrentDir , err = mountDirFromWindowsDir (ctx , inst , hostCurrentDir )
447+ }
448+ return hostCurrentDir , err
449+ }
450+
451+ // Syncs a directory from host to guest and vice-versa. It creates a directory
452+ // named "synced-workdir" in the guest's home directory and copies the contents
453+ // of the host's current working directory into it.
454+ func rsyncDirectory (ctx context.Context , sshCmd * exec.Cmd , source , destination string ) error {
455+ rsyncArgs := []string {
456+ "-ah" ,
457+ "-e" , sshCmd .String (),
458+ source ,
459+ destination ,
460+ }
461+ rsyncCmd := exec .CommandContext (ctx , "rsync" , rsyncArgs ... )
462+ rsyncCmd .Stdin = os .Stdin
463+ rsyncCmd .Stdout = os .Stdout
464+ rsyncCmd .Stderr = os .Stderr
465+ logrus .Debugf ("executing rsync: %+v" , rsyncCmd .Args )
466+ return rsyncCmd .Run ()
300467}
301468
302469func mountDirFromWindowsDir (ctx context.Context , inst * limatype.Instance , dir string ) (string , error ) {
0 commit comments