]> git.ipfire.org Git - thirdparty/mkosi.git/commitdiff
sandbox: split CLI and library entry points to fix console-script execution
authorDaan De Meyer <daan@amutable.com>
Tue, 19 May 2026 14:33:21 +0000 (14:33 +0000)
committerDaan De Meyer <daan.j.demeyer@gmail.com>
Tue, 19 May 2026 20:32:37 +0000 (22:32 +0200)
When installed via pip/pipx/Nix, the mkosi-sandbox console-script calls
main() with __name__ == "mkosi.sandbox" rather than "__main__", so the
old is_main() check returned False and refused to execvp a trailing
command. Fix this by separating the two responsibilities: enter() is the
library function that sets up the sandbox in the current process and
returns any trailing argv, while main() is the CLI wrapper that calls
enter(), prints friendly error messages carried on a new SandboxOSError,
and execvp's the command. The console-script entry point points at
main() and works regardless of how it is invoked.

Fixes: https://github.com/systemd/mkosi/issues/4303
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
mkosi/resources/man/mkosi-sandbox.1.md
mkosi/run.py
mkosi/sandbox.py

index 907a5801d4e992423c09832ecc7b80db805acb14..342372c596634ec7e07ebde8122ff9aaab16e4b0 100644 (file)
@@ -22,17 +22,17 @@ is to allow running commands in an isolated environment so they are not affected
 host system.
 
 It is possible to use `mkosi-sandbox` to create an in process sandbox for python
-applications by importing it as a module and invoking its main function with only
+applications by importing it as a module and invoking its `enter` function with only
 options. No command line to execute is needed in this case. As an example:
 
 ```python
 import mkosi.sandbox
 
-mkosi.sandbox.main(["--become-root"])
+mkosi.sandbox.enter(["--become-root"])
 print(os.getuid())
 ```
 
-Only the `main` function can be invoked. Invoking any other functions
+Only the `enter` function can be invoked. Invoking any other functions
 from `mkosi.sandbox` is not supported and may break in future versions.
 
 # OPTIONS
index 0e292fa9f094a4860ffb45afa3e28b1a0002a30f..6af44d9c7ca49c75dbd709e1f62e6e8263ee6d3f 100644 (file)
@@ -196,7 +196,7 @@ def fork_and_wait(
                 parent.close()
 
                 if sbx:
-                    mkosi.sandbox.main([os.fspath(s) for s in sbx])
+                    mkosi.sandbox.enter([os.fspath(s) for s in sbx])
 
                 child.send(target(*args, **kwargs))
 
@@ -269,12 +269,12 @@ def _preexec(
         # if we get here we should have neither a prefix nor a setup command to execute and so we can
         # execute the command directly.
 
-        # mkosi.sandbox.main() updates os.environ but the environment passed to Popen() is not yet in
+        # mkosi.sandbox.enter() updates os.environ but the environment passed to Popen() is not yet in
         # effect by the time the preexec function is called. To get around that, we update the
         # environment ourselves here.
         os.environ.clear()
         os.environ.update(env)
-        mkosi.sandbox.main(sandbox)
+        mkosi.sandbox.enter(sandbox)
 
         # Python does its own executable lookup in $PATH before executing the preexec function, and
         # hence before we have set up the sandbox which influences the lookup results. To get around
index 877cb08f4b7bf1073e81a32b7d647fbd257cbff1..1594ac9f3708595bdabc5c10afbd1f3b52381963 100755 (executable)
@@ -168,14 +168,22 @@ system call is prohibited via seccomp if mkosi is being executed inside a contai
 """
 
 
-def is_main() -> bool:
-    return __name__ == "__main__"
+class SandboxOSError(OSError):
+    # OSError carrying a human-readable message that the CLI prints to stderr. Library callers can
+    # catch OSError as usual; only the CLI wrapper inspects .message.
+    def __init__(self, errno: int, message: str, filename: str = "") -> None:
+        super().__init__(errno, os.strerror(errno), filename or None)
+        self.message = message
 
 
 def oserror(syscall: str, filename: str = "", errno: int = 0) -> None:
     errno = abs(errno) or ctypes.get_errno()
-    if errno == ENOSYS and is_main():
-        print(ENOSYS_MSG.format(syscall=syscall, kver=os.uname().version), file=sys.stderr)
+    if errno == ENOSYS:
+        raise SandboxOSError(
+            errno,
+            ENOSYS_MSG.format(syscall=syscall, kver=os.uname().version),
+            filename,
+        )
 
     raise OSError(errno, os.strerror(errno), filename or None)
 
@@ -775,8 +783,8 @@ def unprivileged_userns(foreign: bool, become_root: bool) -> None:
         try:
             unshare(CLONE_NEWUSER)
         except OSError as e:
-            if e.errno == EPERM and is_main():
-                print(UNSHARE_EPERM_MSG, file=sys.stderr)
+            if e.errno == EPERM:
+                raise SandboxOSError(EPERM, UNSHARE_EPERM_MSG) from e
             raise
         finally:
             os.write(event, ctypes.c_uint64(1))
@@ -1296,7 +1304,7 @@ e.g. via "mkosi documentation", for workarounds.\
 """
 
 
-def main(argv: list[str] = sys.argv[1:]) -> None:
+def enter(argv: list[str]) -> list[str]:
     # We don't use argparse as it takes +- 10ms to import and since this is primarily for internal
     # use, it's not necessary to have amazing UX for this CLI interface so it's trivial to write
     # ourselves.
@@ -1418,12 +1426,7 @@ def main(argv: list[str] = sys.argv[1:]) -> None:
             break
 
     if argv:
-        if not is_main():
-            raise ValueError(f"A command line to execute can only be provided if {__name__} is executed")
-
         argv.reverse()
-    else:
-        argv = ["bash"] if is_main() else []
 
     # Make sure all destination paths are absolute.
     for fsop in fsops:
@@ -1478,8 +1481,8 @@ def main(argv: list[str] = sys.argv[1:]) -> None:
     except OSError as e:
         # This can happen here as well as in become_user, it depends on exactly
         # how the userns restrictions are implemented.
-        if e.errno == EPERM and is_main():
-            print(UNSHARE_EPERM_MSG, file=sys.stderr)
+        if e.errno == EPERM:
+            raise SandboxOSError(EPERM, UNSHARE_EPERM_MSG) from e
         raise
 
     # If we unshared the user namespace the mount propagation of root is changed to slave automatically.
@@ -1563,17 +1566,28 @@ def main(argv: list[str] = sys.argv[1:]) -> None:
             os.environ["LISTEN_FDS"] = str(nfds)
             os.environ["LISTEN_PID"] = str(os.getpid())
 
-    if is_main():
-        try:
-            os.execvp(argv[0], argv)
-        except OSError as e:
-            # Let's return a recognizable error when the binary we're going to execute is not found.
-            # We use 127 as that's the exit code used by shells when a program to execute is not found.
-            if e.errno == ENOENT:
-                sys.exit(127)
+    return argv
 
-            raise
+
+def main(argv: list[str] = sys.argv[1:]) -> None:
+    try:
+        argv = enter(argv)
+    except SandboxOSError as e:
+        print(e.message, file=sys.stderr)
+        raise
+
+    argv = argv or ["bash"]
+
+    try:
+        os.execvp(argv[0], argv)
+    except OSError as e:
+        # Let's return a recognizable error when the binary we're going to execute is not found.
+        # We use 127 as that's the exit code used by shells when a program to execute is not found.
+        if e.errno == ENOENT:
+            sys.exit(127)
+
+        raise
 
 
-if is_main():
+if __name__ == "__main__":
     main()