boxlite 0.4.0.dev0__cp311-cp311-macosx_14_0_arm64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of boxlite might be problematic. Click here for more details.
- boxlite/__init__.py +92 -0
- boxlite/boxlite.cpython-311-darwin.so +0 -0
- boxlite/browserbox.py +138 -0
- boxlite/codebox.py +120 -0
- boxlite/computerbox.py +267 -0
- boxlite/constants.py +25 -0
- boxlite/errors.py +38 -0
- boxlite/exec.py +26 -0
- boxlite/interactivebox.py +297 -0
- boxlite/runtime/boxlite-guest +0 -0
- boxlite/runtime/boxlite-shim +0 -0
- boxlite/runtime/debugfs +0 -0
- boxlite/runtime/libgvproxy.dylib +0 -0
- boxlite/runtime/libkrun.1.15.1.dylib +0 -0
- boxlite/runtime/libkrunfw.4.dylib +0 -0
- boxlite/runtime/mke2fs +0 -0
- boxlite/simplebox.py +213 -0
- boxlite-0.4.0.dev0.dist-info/METADATA +10 -0
- boxlite-0.4.0.dev0.dist-info/RECORD +20 -0
- boxlite-0.4.0.dev0.dist-info/WHEEL +4 -0
boxlite/__init__.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""
|
|
2
|
+
BoxLite - Lightweight, secure containerization for any environment.
|
|
3
|
+
|
|
4
|
+
Following SQLite philosophy: "BoxLite" for branding, "boxlite" for code APIs.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import warnings
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
# Import core Rust API
|
|
12
|
+
try:
|
|
13
|
+
from .boxlite import (
|
|
14
|
+
Options,
|
|
15
|
+
BoxOptions,
|
|
16
|
+
Boxlite,
|
|
17
|
+
Box,
|
|
18
|
+
Execution,
|
|
19
|
+
ExecStdout,
|
|
20
|
+
ExecStderr,
|
|
21
|
+
BoxInfo,
|
|
22
|
+
RuntimeMetrics,
|
|
23
|
+
BoxMetrics,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
# Core Rust API
|
|
28
|
+
"Options",
|
|
29
|
+
"BoxOptions",
|
|
30
|
+
"Boxlite",
|
|
31
|
+
"Box",
|
|
32
|
+
"Execution",
|
|
33
|
+
"ExecStdout",
|
|
34
|
+
"ExecStderr",
|
|
35
|
+
"BoxInfo",
|
|
36
|
+
"RuntimeMetrics",
|
|
37
|
+
"BoxMetrics",
|
|
38
|
+
]
|
|
39
|
+
except ImportError as e:
|
|
40
|
+
warnings.warn(f"BoxLite native extension not available: {e}", ImportWarning)
|
|
41
|
+
__all__ = []
|
|
42
|
+
|
|
43
|
+
# Import Python convenience wrappers
|
|
44
|
+
try:
|
|
45
|
+
from .simplebox import SimpleBox
|
|
46
|
+
from .exec import ExecResult
|
|
47
|
+
from .codebox import CodeBox
|
|
48
|
+
from .errors import BoxliteError, ExecError, TimeoutError, ParseError
|
|
49
|
+
|
|
50
|
+
__all__.extend([
|
|
51
|
+
# Python convenience wrappers
|
|
52
|
+
"SimpleBox",
|
|
53
|
+
"CodeBox",
|
|
54
|
+
"ExecResult",
|
|
55
|
+
# Error types
|
|
56
|
+
"BoxliteError",
|
|
57
|
+
"ExecError",
|
|
58
|
+
"TimeoutError",
|
|
59
|
+
"ParseError",
|
|
60
|
+
])
|
|
61
|
+
except ImportError:
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
# Specialized containers
|
|
65
|
+
try:
|
|
66
|
+
from .browserbox import BrowserBox, BrowserBoxOptions
|
|
67
|
+
|
|
68
|
+
__all__.extend(["BrowserBox", "BrowserBoxOptions"])
|
|
69
|
+
except ImportError:
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
from .computerbox import ComputerBox
|
|
74
|
+
|
|
75
|
+
__all__.extend(["ComputerBox"])
|
|
76
|
+
except ImportError:
|
|
77
|
+
pass
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
from .interactivebox import InteractiveBox
|
|
81
|
+
|
|
82
|
+
__all__.extend(["InteractiveBox"])
|
|
83
|
+
except ImportError:
|
|
84
|
+
pass
|
|
85
|
+
|
|
86
|
+
# Get version from package metadata
|
|
87
|
+
try:
|
|
88
|
+
from importlib.metadata import version, PackageNotFoundError
|
|
89
|
+
__version__ = version("boxlite")
|
|
90
|
+
except PackageNotFoundError:
|
|
91
|
+
# Package not installed (e.g., development mode)
|
|
92
|
+
__version__ = "0.0.0+dev"
|
|
Binary file
|
boxlite/browserbox.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""
|
|
2
|
+
BrowserBox - Secure browser with remote debugging.
|
|
3
|
+
|
|
4
|
+
Provides a minimal, elegant API for running isolated browsers that can be
|
|
5
|
+
controlled from outside using standard tools like Puppeteer or Playwright.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Optional, TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
from .simplebox import SimpleBox, StreamType
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from .boxlite import Boxlite
|
|
15
|
+
|
|
16
|
+
__all__ = ["BrowserBox", "BrowserBoxOptions"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class BrowserBoxOptions:
|
|
21
|
+
"""
|
|
22
|
+
Configuration for BrowserBox.
|
|
23
|
+
|
|
24
|
+
Example:
|
|
25
|
+
>>> opts = BrowserBoxOptions(
|
|
26
|
+
... browser="chromium",
|
|
27
|
+
... memory=2048,
|
|
28
|
+
... cpu=2
|
|
29
|
+
... )
|
|
30
|
+
>>> async with BrowserBox(opts) as browser:
|
|
31
|
+
... print(browser.endpoint())
|
|
32
|
+
"""
|
|
33
|
+
browser: str = "chromium" # chromium, firefox, or webkit
|
|
34
|
+
memory: int = 2048 # Memory in MiB
|
|
35
|
+
cpu: int = 2 # Number of CPU cores
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class BrowserBox(SimpleBox):
|
|
39
|
+
"""
|
|
40
|
+
Secure browser environment with remote debugging.
|
|
41
|
+
|
|
42
|
+
Auto-starts a browser with Chrome DevTools Protocol enabled.
|
|
43
|
+
Connect from outside using Puppeteer, Playwright, Selenium, or DevTools.
|
|
44
|
+
|
|
45
|
+
Usage:
|
|
46
|
+
>>> async with BrowserBox() as browser:
|
|
47
|
+
... print(f"Connect to: {browser.endpoint()}")
|
|
48
|
+
... # Use Puppeteer/Playwright from your host to connect
|
|
49
|
+
... await asyncio.sleep(60)
|
|
50
|
+
|
|
51
|
+
Example with custom options:
|
|
52
|
+
>>> opts = BrowserBoxOptions(browser="firefox", memory=4096)
|
|
53
|
+
>>> async with BrowserBox(opts) as browser:
|
|
54
|
+
... endpoint = browser.endpoint()
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
# Default Playwright images (with retry logic now!)
|
|
58
|
+
_DEFAULT_IMAGE = "mcr.microsoft.com/playwright:v1.47.2-jammy"
|
|
59
|
+
|
|
60
|
+
# CDP port for each browser type
|
|
61
|
+
_PORTS = {"chromium": 9222, "firefox": 9223, "webkit": 9224}
|
|
62
|
+
|
|
63
|
+
def __init__(self, options: Optional[BrowserBoxOptions] = None,
|
|
64
|
+
runtime: Optional['Boxlite'] = None,
|
|
65
|
+
**kwargs):
|
|
66
|
+
"""
|
|
67
|
+
Create and auto-start a browser.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
options: Browser configuration (uses defaults if None)
|
|
71
|
+
runtime: Optional runtime instance (uses global default if None)
|
|
72
|
+
**kwargs: Additional configuration options (volumes, env, etc.)
|
|
73
|
+
"""
|
|
74
|
+
opts = options or BrowserBoxOptions()
|
|
75
|
+
|
|
76
|
+
self._browser = opts.browser
|
|
77
|
+
self._port = self._PORTS.get(opts.browser, 9222)
|
|
78
|
+
|
|
79
|
+
# Initialize base box
|
|
80
|
+
super().__init__(
|
|
81
|
+
image=self._DEFAULT_IMAGE,
|
|
82
|
+
memory_mib=opts.memory,
|
|
83
|
+
cpus=opts.cpu,
|
|
84
|
+
runtime=runtime,
|
|
85
|
+
**kwargs
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
async def __aenter__(self):
|
|
89
|
+
"""Start browser automatically on context enter."""
|
|
90
|
+
await super().__aenter__()
|
|
91
|
+
await self._start_browser()
|
|
92
|
+
return self
|
|
93
|
+
|
|
94
|
+
async def _start_browser(self):
|
|
95
|
+
"""Internal: Start browser with remote debugging."""
|
|
96
|
+
if self._browser == "chromium":
|
|
97
|
+
binary = "/ms-playwright/chromium-*/chrome-linux/chrome"
|
|
98
|
+
cmd = (
|
|
99
|
+
f"{binary} --headless --no-sandbox --disable-dev-shm-usage "
|
|
100
|
+
f"--disable-gpu --remote-debugging-address=0.0.0.0 "
|
|
101
|
+
f"--remote-debugging-port={self._port} "
|
|
102
|
+
f"> /tmp/browser.log 2>&1 &"
|
|
103
|
+
)
|
|
104
|
+
elif self._browser == "firefox":
|
|
105
|
+
binary = "/ms-playwright/firefox-*/firefox/firefox"
|
|
106
|
+
cmd = (
|
|
107
|
+
f"{binary} --headless "
|
|
108
|
+
f"--remote-debugging-port={self._port} "
|
|
109
|
+
f"> /tmp/browser.log 2>&1 &"
|
|
110
|
+
)
|
|
111
|
+
else: # webkit
|
|
112
|
+
cmd = (
|
|
113
|
+
f"playwright run-server --browser webkit "
|
|
114
|
+
f"--port {self._port} > /tmp/browser.log 2>&1 &"
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
# Start browser in background
|
|
118
|
+
await self.exec("sh", "-c", f"nohup {cmd}")
|
|
119
|
+
|
|
120
|
+
# Wait for browser to be ready
|
|
121
|
+
await self.exec("sleep", "3")
|
|
122
|
+
|
|
123
|
+
def endpoint(self) -> str:
|
|
124
|
+
"""
|
|
125
|
+
Get the connection endpoint for remote debugging.
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
HTTP endpoint URL for Chrome DevTools Protocol
|
|
129
|
+
|
|
130
|
+
Example:
|
|
131
|
+
>>> async with BrowserBox() as browser:
|
|
132
|
+
... url = browser.endpoint()
|
|
133
|
+
... # Use with Puppeteer:
|
|
134
|
+
... # puppeteer.connect({ browserURL: url })
|
|
135
|
+
... # Use with Playwright:
|
|
136
|
+
... # chromium.connect_over_cdp(url)
|
|
137
|
+
"""
|
|
138
|
+
return f"http://localhost:{self._port}"
|
boxlite/codebox.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CodeBox - Secure Python code execution container.
|
|
3
|
+
|
|
4
|
+
Provides a simple, secure environment for running untrusted Python code.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Optional, TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from .simplebox import SimpleBox
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from .boxlite import Boxlite
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CodeBox(SimpleBox):
|
|
16
|
+
"""
|
|
17
|
+
Secure container for executing Python code.
|
|
18
|
+
|
|
19
|
+
CodeBox provides an isolated environment for running untrusted Python code
|
|
20
|
+
with built-in safety and result formatting.
|
|
21
|
+
|
|
22
|
+
Usage:
|
|
23
|
+
>>> async with CodeBox() as cb:
|
|
24
|
+
... result = await cb.run("print('Hello, World!')")
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
image: str = "python:slim",
|
|
30
|
+
memory_mib: Optional[int] = None,
|
|
31
|
+
cpus: Optional[int] = None,
|
|
32
|
+
runtime: Optional['Boxlite'] = None,
|
|
33
|
+
**kwargs
|
|
34
|
+
):
|
|
35
|
+
"""
|
|
36
|
+
Create a new CodeBox.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
image: Container images with Python (default: python:slim)
|
|
40
|
+
memory_mib: Memory limit in MiB (default: system default)
|
|
41
|
+
cpus: Number of CPU cores (default: system default)
|
|
42
|
+
runtime: Optional runtime instance (uses global default if None)
|
|
43
|
+
**kwargs: Additional configuration options
|
|
44
|
+
"""
|
|
45
|
+
super().__init__(image, memory_mib, cpus, runtime, **kwargs)
|
|
46
|
+
|
|
47
|
+
async def run(self, code: str, timeout: Optional[int] = None) -> str:
|
|
48
|
+
"""
|
|
49
|
+
Execute Python code in the secure container.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
code: Python code to execute
|
|
53
|
+
timeout: Execution timeout in seconds (not yet implemented)
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
Execution output as a string (stdout + stderr)
|
|
57
|
+
|
|
58
|
+
Example:
|
|
59
|
+
>>> async with CodeBox() as cb:
|
|
60
|
+
... result = await cb.run("print('Hello, World!')")
|
|
61
|
+
... print(result)
|
|
62
|
+
Hello, World!
|
|
63
|
+
|
|
64
|
+
Note:
|
|
65
|
+
Uses python3 from the container images.
|
|
66
|
+
For custom Python paths, use exec() directly:
|
|
67
|
+
result = await cb.exec("/path/to/python", "-c", code)
|
|
68
|
+
"""
|
|
69
|
+
# Execute Python code using python3 -c
|
|
70
|
+
result = await self.exec("/usr/local/bin/python", "-c", code)
|
|
71
|
+
return result.stdout + result.stderr
|
|
72
|
+
|
|
73
|
+
async def run_script(self, script_path: str) -> str:
|
|
74
|
+
"""
|
|
75
|
+
Execute a Python script file in the container.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
script_path: Path to the Python script on the host
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
Execution output as a string
|
|
82
|
+
"""
|
|
83
|
+
with open(script_path, 'r') as f:
|
|
84
|
+
code = f.read()
|
|
85
|
+
return await self.run(code)
|
|
86
|
+
|
|
87
|
+
async def install_package(self, package: str) -> str:
|
|
88
|
+
"""
|
|
89
|
+
Install a Python package in the container using pip.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
package: Package name (e.g., 'requests', 'numpy==1.24.0')
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
Installation output
|
|
96
|
+
|
|
97
|
+
Example:
|
|
98
|
+
>>> async with CodeBox() as cb:
|
|
99
|
+
... await cb.install_package("requests")
|
|
100
|
+
... result = await cb.run("import requests; print(requests.__version__)")
|
|
101
|
+
"""
|
|
102
|
+
result = await self.exec("pip", "install", package)
|
|
103
|
+
return result.stdout + result.stderr
|
|
104
|
+
|
|
105
|
+
async def install_packages(self, *packages: str) -> str:
|
|
106
|
+
"""
|
|
107
|
+
Install multiple Python packages.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
*packages: Package names to install
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
Installation output
|
|
114
|
+
|
|
115
|
+
Example:
|
|
116
|
+
>>> async with CodeBox() as cb:
|
|
117
|
+
... await cb.install_packages("requests", "numpy", "pandas")
|
|
118
|
+
"""
|
|
119
|
+
result = await self.exec("pip", "install", *packages)
|
|
120
|
+
return result.stdout + result.stderr
|
boxlite/computerbox.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ComputerBox - Desktop environment with web access.
|
|
3
|
+
|
|
4
|
+
Provides a minimal, elegant API for running isolated desktop environments
|
|
5
|
+
that can be viewed from a browser, with full GUI automation support.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import logging
|
|
10
|
+
from typing import Optional, Tuple, TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from . import constants as const
|
|
13
|
+
from .errors import ExecError, TimeoutError, ParseError
|
|
14
|
+
from .simplebox import SimpleBox
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from .boxlite import Boxlite
|
|
18
|
+
|
|
19
|
+
__all__ = ["ComputerBox"]
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger("boxlite.computerbox")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ComputerBox(SimpleBox):
|
|
25
|
+
"""
|
|
26
|
+
Desktop environment accessible via web browser.
|
|
27
|
+
|
|
28
|
+
Auto-starts a full desktop environment with web interface.
|
|
29
|
+
Access the desktop by opening the URL in your browser.
|
|
30
|
+
|
|
31
|
+
Note: Uses HTTPS with self-signed certificate - your browser will show
|
|
32
|
+
a security warning. Click "Advanced" and "Proceed" to access the desktop.
|
|
33
|
+
|
|
34
|
+
Usage:
|
|
35
|
+
>>> async with ComputerBox() as desktop:
|
|
36
|
+
... await desktop.wait_until_ready()
|
|
37
|
+
... screenshot = await desktop.screenshot()
|
|
38
|
+
|
|
39
|
+
Example with custom settings:
|
|
40
|
+
>>> async with ComputerBox(memory=4096, cpu=4) as desktop:
|
|
41
|
+
... await desktop.mouse_move(100, 200)
|
|
42
|
+
... await desktop.left_click()
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
cpu: int = const.COMPUTERBOX_CPUS,
|
|
48
|
+
memory: int = const.COMPUTERBOX_MEMORY_MIB,
|
|
49
|
+
gui_http_port: int = const.COMPUTERBOX_GUI_HTTP_PORT,
|
|
50
|
+
gui_https_port: int = const.COMPUTERBOX_GUI_HTTPS_PORT,
|
|
51
|
+
runtime: Optional['Boxlite'] = None,
|
|
52
|
+
**kwargs
|
|
53
|
+
):
|
|
54
|
+
"""
|
|
55
|
+
Create and auto-start a desktop environment.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
cpu: Number of CPU cores (default: 2)
|
|
59
|
+
memory: Memory in MiB (default: 2048)
|
|
60
|
+
gui_http_port: Port for HTTP desktop GUI (default: 3000)
|
|
61
|
+
gui_https_port: Port for HTTPS desktop GUI (default: 3001)
|
|
62
|
+
runtime: Optional runtime instance (uses global default if None)
|
|
63
|
+
**kwargs: Additional configuration options (volumes, etc.)
|
|
64
|
+
"""
|
|
65
|
+
user_env = kwargs.pop('env', [])
|
|
66
|
+
default_env = [
|
|
67
|
+
("DISPLAY", const.COMPUTERBOX_DISPLAY_NUMBER),
|
|
68
|
+
("DISPLAY_SIZEW", str(const.COMPUTERBOX_DISPLAY_WIDTH)),
|
|
69
|
+
("DISPLAY_SIZEH", str(const.COMPUTERBOX_DISPLAY_HEIGHT)),
|
|
70
|
+
("SELKIES_MANUAL_WIDTH", str(const.COMPUTERBOX_DISPLAY_WIDTH)),
|
|
71
|
+
("SELKIES_MANUAL_HEIGHT", str(const.COMPUTERBOX_DISPLAY_HEIGHT)),
|
|
72
|
+
("SELKIES_UI_SHOW_SIDEBAR", "false"),
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
user_ports = kwargs.pop('ports', [])
|
|
76
|
+
default_ports = [
|
|
77
|
+
(gui_http_port, const.COMPUTERBOX_GUI_HTTP_PORT),
|
|
78
|
+
(gui_https_port, const.COMPUTERBOX_GUI_HTTPS_PORT)
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
super().__init__(
|
|
82
|
+
image=const.COMPUTERBOX_IMAGE,
|
|
83
|
+
memory_mib=memory,
|
|
84
|
+
cpus=cpu,
|
|
85
|
+
runtime=runtime,
|
|
86
|
+
env=default_env + list(user_env),
|
|
87
|
+
ports=default_ports + list(user_ports),
|
|
88
|
+
**kwargs
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
async def wait_until_ready(self, timeout: int = const.DESKTOP_READY_TIMEOUT):
|
|
92
|
+
"""
|
|
93
|
+
Wait until the desktop environment is fully loaded and ready.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
timeout: Maximum time to wait in seconds (default: 60)
|
|
97
|
+
|
|
98
|
+
Raises:
|
|
99
|
+
TimeoutError: If desktop doesn't become ready within timeout period
|
|
100
|
+
"""
|
|
101
|
+
logger.info("Waiting for desktop to become ready...")
|
|
102
|
+
import time
|
|
103
|
+
start_time = time.time()
|
|
104
|
+
|
|
105
|
+
while True:
|
|
106
|
+
elapsed = time.time() - start_time
|
|
107
|
+
if elapsed > timeout:
|
|
108
|
+
raise TimeoutError(f"Desktop did not become ready within {timeout} seconds")
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
exec_result = await self.exec("xwininfo", "-tree", "-root")
|
|
112
|
+
expected_size = f'{const.COMPUTERBOX_DISPLAY_WIDTH}x{const.COMPUTERBOX_DISPLAY_HEIGHT}'
|
|
113
|
+
|
|
114
|
+
if 'xfdesktop' in exec_result.stdout and expected_size in exec_result.stdout:
|
|
115
|
+
logger.info(f"Desktop ready after {elapsed:.1f} seconds")
|
|
116
|
+
return
|
|
117
|
+
|
|
118
|
+
logger.debug(f"Desktop not ready yet (waited {elapsed:.1f}s), retrying...")
|
|
119
|
+
await asyncio.sleep(const.DESKTOP_READY_RETRY_DELAY)
|
|
120
|
+
|
|
121
|
+
except (ExecError, ConnectionError, OSError, asyncio.TimeoutError) as e:
|
|
122
|
+
logger.debug(f"Desktop not ready: {e}, retrying...")
|
|
123
|
+
await asyncio.sleep(const.DESKTOP_READY_RETRY_DELAY)
|
|
124
|
+
except Exception as e:
|
|
125
|
+
logger.error(f"Fatal error in wait_until_ready: {e}")
|
|
126
|
+
raise
|
|
127
|
+
|
|
128
|
+
async def screenshot(self) -> dict:
|
|
129
|
+
"""
|
|
130
|
+
Capture a screenshot of the desktop.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
Dictionary with: data (base64 PNG), width, height, format
|
|
134
|
+
"""
|
|
135
|
+
logger.info("Taking screenshot...")
|
|
136
|
+
|
|
137
|
+
python_code = '''
|
|
138
|
+
from PIL import ImageGrab
|
|
139
|
+
import io
|
|
140
|
+
import base64
|
|
141
|
+
img = ImageGrab.grab()
|
|
142
|
+
buffer = io.BytesIO()
|
|
143
|
+
img.save(buffer, format="PNG")
|
|
144
|
+
print(base64.b64encode(buffer.getvalue()).decode("utf-8"))
|
|
145
|
+
'''
|
|
146
|
+
exec_result = await self.exec("python3", "-c", python_code)
|
|
147
|
+
|
|
148
|
+
if exec_result.exit_code != 0:
|
|
149
|
+
raise ExecError("screenshot()", exec_result.exit_code, exec_result.stderr)
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
"data": exec_result.stdout.strip(),
|
|
153
|
+
"width": const.COMPUTERBOX_DISPLAY_WIDTH,
|
|
154
|
+
"height": const.COMPUTERBOX_DISPLAY_HEIGHT,
|
|
155
|
+
"format": "png"
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async def mouse_move(self, x: int, y: int):
|
|
159
|
+
"""Move mouse cursor to absolute coordinates."""
|
|
160
|
+
exec_result = await self.exec("xdotool", "mousemove", str(x), str(y))
|
|
161
|
+
if exec_result.exit_code != 0:
|
|
162
|
+
raise ExecError(f"mouse_move({x}, {y})", exec_result.exit_code, exec_result.stderr)
|
|
163
|
+
|
|
164
|
+
async def left_click(self):
|
|
165
|
+
"""Click left mouse button at current position."""
|
|
166
|
+
exec_result = await self.exec("xdotool", "click", "1")
|
|
167
|
+
if exec_result.exit_code != 0:
|
|
168
|
+
raise ExecError("left_click()", exec_result.exit_code, exec_result.stderr)
|
|
169
|
+
|
|
170
|
+
async def right_click(self):
|
|
171
|
+
"""Click right mouse button at current position."""
|
|
172
|
+
exec_result = await self.exec("xdotool", "click", "3")
|
|
173
|
+
if exec_result.exit_code != 0:
|
|
174
|
+
raise ExecError("right_click()", exec_result.exit_code, exec_result.stderr)
|
|
175
|
+
|
|
176
|
+
async def middle_click(self):
|
|
177
|
+
"""Click middle mouse button at current position."""
|
|
178
|
+
exec_result = await self.exec("xdotool", "click", "2")
|
|
179
|
+
if exec_result.exit_code != 0:
|
|
180
|
+
raise ExecError("middle_click()", exec_result.exit_code, exec_result.stderr)
|
|
181
|
+
|
|
182
|
+
async def double_click(self):
|
|
183
|
+
"""Double-click left mouse button at current position."""
|
|
184
|
+
exec_result = await self.exec("xdotool", "click", "--repeat", "2", "--delay", "100", "1")
|
|
185
|
+
if exec_result.exit_code != 0:
|
|
186
|
+
raise ExecError("double_click()", exec_result.exit_code, exec_result.stderr)
|
|
187
|
+
|
|
188
|
+
async def triple_click(self):
|
|
189
|
+
"""Triple-click left mouse button at current position."""
|
|
190
|
+
exec_result = await self.exec("xdotool", "click", "--repeat", "3", "--delay", "100", "1")
|
|
191
|
+
if exec_result.exit_code != 0:
|
|
192
|
+
raise ExecError("triple_click()", exec_result.exit_code, exec_result.stderr)
|
|
193
|
+
|
|
194
|
+
async def left_click_drag(self, start_x: int, start_y: int, end_x: int, end_y: int):
|
|
195
|
+
"""Drag mouse from start position to end position with left button held."""
|
|
196
|
+
exec_result = await self.exec(
|
|
197
|
+
"xdotool",
|
|
198
|
+
"mousemove", str(start_x), str(start_y),
|
|
199
|
+
"mousedown", "1",
|
|
200
|
+
"sleep", "0.1",
|
|
201
|
+
"mousemove", str(end_x), str(end_y),
|
|
202
|
+
"sleep", "0.1",
|
|
203
|
+
"mouseup", "1"
|
|
204
|
+
)
|
|
205
|
+
if exec_result.exit_code != 0:
|
|
206
|
+
raise ExecError("left_click_drag()", exec_result.exit_code, exec_result.stderr)
|
|
207
|
+
|
|
208
|
+
async def cursor_position(self) -> Tuple[int, int]:
|
|
209
|
+
"""Get the current mouse cursor position. Returns (x, y) tuple."""
|
|
210
|
+
exec_result = await self.exec("xdotool", "getmouselocation", "--shell")
|
|
211
|
+
if exec_result.exit_code != 0:
|
|
212
|
+
raise ExecError("cursor_position()", exec_result.exit_code, exec_result.stderr)
|
|
213
|
+
|
|
214
|
+
x, y = None, None
|
|
215
|
+
for line in exec_result.stdout.split('\n'):
|
|
216
|
+
line = line.strip()
|
|
217
|
+
if line.startswith('X='):
|
|
218
|
+
x = int(line[2:])
|
|
219
|
+
elif line.startswith('Y='):
|
|
220
|
+
y = int(line[2:])
|
|
221
|
+
|
|
222
|
+
if x is not None and y is not None:
|
|
223
|
+
return (x, y)
|
|
224
|
+
raise ParseError("Failed to parse cursor position from xdotool output")
|
|
225
|
+
|
|
226
|
+
async def type(self, text: str):
|
|
227
|
+
"""Type text using the keyboard."""
|
|
228
|
+
exec_result = await self.exec("xdotool", "type", "--", text)
|
|
229
|
+
if exec_result.exit_code != 0:
|
|
230
|
+
raise ExecError("type()", exec_result.exit_code, exec_result.stderr)
|
|
231
|
+
|
|
232
|
+
async def key(self, text: str):
|
|
233
|
+
"""Press a special key or key combination (e.g., 'Return', 'ctrl+c')."""
|
|
234
|
+
exec_result = await self.exec("xdotool", "key", text)
|
|
235
|
+
if exec_result.exit_code != 0:
|
|
236
|
+
raise ExecError("key()", exec_result.exit_code, exec_result.stderr)
|
|
237
|
+
|
|
238
|
+
async def scroll(self, x: int, y: int, direction: str, amount: int = 3):
|
|
239
|
+
"""
|
|
240
|
+
Scroll at a specific position.
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
x, y: Coordinates where to scroll
|
|
244
|
+
direction: 'up', 'down', 'left', or 'right'
|
|
245
|
+
amount: Number of scroll units (default: 3)
|
|
246
|
+
"""
|
|
247
|
+
direction_map = {"up": "4", "down": "5", "left": "6", "right": "7"}
|
|
248
|
+
button = direction_map.get(direction.lower())
|
|
249
|
+
if not button:
|
|
250
|
+
raise ValueError(f"Invalid scroll direction: {direction}")
|
|
251
|
+
|
|
252
|
+
exec_result = await self.exec(
|
|
253
|
+
"xdotool", "mousemove", str(x), str(y), "click", "--repeat", str(amount), button
|
|
254
|
+
)
|
|
255
|
+
if exec_result.exit_code != 0:
|
|
256
|
+
raise ExecError("scroll()", exec_result.exit_code, exec_result.stderr)
|
|
257
|
+
|
|
258
|
+
async def get_screen_size(self) -> Tuple[int, int]:
|
|
259
|
+
"""Get the screen resolution. Returns (width, height) tuple."""
|
|
260
|
+
exec_result = await self.exec("xdotool", "getdisplaygeometry")
|
|
261
|
+
if exec_result.exit_code != 0:
|
|
262
|
+
raise ExecError("get_screen_size()", exec_result.exit_code, exec_result.stderr)
|
|
263
|
+
|
|
264
|
+
parts = exec_result.stdout.strip().split()
|
|
265
|
+
if len(parts) == 2:
|
|
266
|
+
return (int(parts[0]), int(parts[1]))
|
|
267
|
+
raise ParseError("Failed to parse screen size from xdotool output")
|
boxlite/constants.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Centralized constants for BoxLite Python SDK.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
# Default VM resources
|
|
6
|
+
DEFAULT_CPUS = 1
|
|
7
|
+
DEFAULT_MEMORY_MIB = 2048
|
|
8
|
+
|
|
9
|
+
# ComputerBox defaults (higher resources for desktop)
|
|
10
|
+
COMPUTERBOX_CPUS = 2
|
|
11
|
+
COMPUTERBOX_MEMORY_MIB = 2048
|
|
12
|
+
COMPUTERBOX_IMAGE = "lscr.io/linuxserver/webtop:ubuntu-xfce"
|
|
13
|
+
|
|
14
|
+
# ComputerBox display settings
|
|
15
|
+
COMPUTERBOX_DISPLAY_NUMBER = ":1"
|
|
16
|
+
COMPUTERBOX_DISPLAY_WIDTH = 1024
|
|
17
|
+
COMPUTERBOX_DISPLAY_HEIGHT = 768
|
|
18
|
+
|
|
19
|
+
# ComputerBox network ports (webtop defaults)
|
|
20
|
+
COMPUTERBOX_GUI_HTTP_PORT = 3000
|
|
21
|
+
COMPUTERBOX_GUI_HTTPS_PORT = 3001
|
|
22
|
+
|
|
23
|
+
# Timeouts (seconds)
|
|
24
|
+
DESKTOP_READY_TIMEOUT = 60
|
|
25
|
+
DESKTOP_READY_RETRY_DELAY = 0.5
|
boxlite/errors.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""
|
|
2
|
+
BoxLite error types.
|
|
3
|
+
|
|
4
|
+
Provides a hierarchy of exceptions for different failure modes.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
__all__ = ['BoxliteError', 'ExecError', 'TimeoutError', 'ParseError']
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class BoxliteError(Exception):
|
|
11
|
+
"""Base exception for all boxlite errors."""
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ExecError(BoxliteError):
|
|
16
|
+
"""
|
|
17
|
+
Raised when a command execution fails (non-zero exit code).
|
|
18
|
+
|
|
19
|
+
Attributes:
|
|
20
|
+
command: The command that failed
|
|
21
|
+
exit_code: The non-zero exit code
|
|
22
|
+
stderr: Standard error output from the command
|
|
23
|
+
"""
|
|
24
|
+
def __init__(self, command: str, exit_code: int, stderr: str):
|
|
25
|
+
self.command = command
|
|
26
|
+
self.exit_code = exit_code
|
|
27
|
+
self.stderr = stderr
|
|
28
|
+
super().__init__(f"Command '{command}' failed with exit code {exit_code}: {stderr}")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class TimeoutError(BoxliteError):
|
|
32
|
+
"""Raised when an operation times out."""
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ParseError(BoxliteError):
|
|
37
|
+
"""Raised when output parsing fails."""
|
|
38
|
+
pass
|
boxlite/exec.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Execution API - Simple interface for command execution.
|
|
3
|
+
|
|
4
|
+
Provides Docker-like API for executing commands in boxes.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
'ExecResult',
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class ExecResult:
|
|
16
|
+
"""
|
|
17
|
+
Result from a command execution.
|
|
18
|
+
|
|
19
|
+
Attributes:
|
|
20
|
+
exit_code: Exit code from the command (negative if terminated by signal)
|
|
21
|
+
stdout: Standard output as string
|
|
22
|
+
stderr: Standard error as string
|
|
23
|
+
"""
|
|
24
|
+
exit_code: int
|
|
25
|
+
stdout: str
|
|
26
|
+
stderr: str
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""
|
|
2
|
+
InteractiveBox - Interactive terminal sessions with PTY support.
|
|
3
|
+
|
|
4
|
+
Provides automatic PTY-based interactive sessions, similar to `docker exec -it`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
import termios
|
|
12
|
+
import tty
|
|
13
|
+
from typing import Optional, TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from .runtime import Boxlite
|
|
17
|
+
|
|
18
|
+
# Configure logger
|
|
19
|
+
logger = logging.getLogger("boxlite.interactivebox")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class InteractiveBox:
|
|
23
|
+
"""
|
|
24
|
+
Interactive box with automatic PTY and terminal forwarding.
|
|
25
|
+
|
|
26
|
+
When used as a context manager, automatically:
|
|
27
|
+
1. Auto-detects terminal size (like Docker)
|
|
28
|
+
2. Starts a shell with PTY
|
|
29
|
+
3. Sets local terminal to cbreak mode
|
|
30
|
+
4. Forwards stdin/stdout bidirectionally
|
|
31
|
+
5. Restores terminal mode on exit
|
|
32
|
+
|
|
33
|
+
Example:
|
|
34
|
+
async with InteractiveBox(image="alpine:latest") as box:
|
|
35
|
+
# You're now in an interactive shell!
|
|
36
|
+
# Type commands, see output in real-time
|
|
37
|
+
# Type "exit" to close
|
|
38
|
+
pass
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
image: str,
|
|
44
|
+
shell: str = "/bin/sh",
|
|
45
|
+
tty: Optional[bool] = None,
|
|
46
|
+
memory_mib: Optional[int] = None,
|
|
47
|
+
cpus: Optional[int] = None,
|
|
48
|
+
runtime: Optional['Boxlite'] = None,
|
|
49
|
+
name: Optional[str] = None,
|
|
50
|
+
auto_remove: bool = True,
|
|
51
|
+
**kwargs
|
|
52
|
+
):
|
|
53
|
+
"""
|
|
54
|
+
Create interactive box.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
image: Container image to use
|
|
58
|
+
shell: Shell to run (default: /bin/sh)
|
|
59
|
+
tty: Control terminal I/O forwarding behavior:
|
|
60
|
+
- None (default): Auto-detect - forward I/O if stdin is a TTY
|
|
61
|
+
- True: Force I/O forwarding (manual interactive mode)
|
|
62
|
+
- False: No I/O forwarding (programmatic control only)
|
|
63
|
+
memory_mib: Memory limit in MiB
|
|
64
|
+
cpus: Number of CPU cores
|
|
65
|
+
runtime: Optional runtime instance (uses global default if None)
|
|
66
|
+
name: Optional name for the box (must be unique)
|
|
67
|
+
auto_remove: Remove box when stopped (default: True)
|
|
68
|
+
**kwargs: Additional configuration options (working_dir, env)
|
|
69
|
+
"""
|
|
70
|
+
try:
|
|
71
|
+
from .boxlite import Boxlite, BoxOptions
|
|
72
|
+
except ImportError as e:
|
|
73
|
+
raise ImportError(
|
|
74
|
+
f"BoxLite native extension not found: {e}. "
|
|
75
|
+
"Please install with: pip install boxlite"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# Use provided runtime or get default
|
|
79
|
+
if runtime is None:
|
|
80
|
+
self._runtime = Boxlite.default()
|
|
81
|
+
else:
|
|
82
|
+
self._runtime = runtime
|
|
83
|
+
|
|
84
|
+
# Create box options
|
|
85
|
+
box_opts = BoxOptions(
|
|
86
|
+
image=image,
|
|
87
|
+
cpus=cpus,
|
|
88
|
+
memory_mib=memory_mib,
|
|
89
|
+
auto_remove=auto_remove,
|
|
90
|
+
**kwargs
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# Create box directly (no SimpleBox wrapper)
|
|
94
|
+
self._box = self._runtime.create(box_opts, name=name)
|
|
95
|
+
|
|
96
|
+
# Store interactive config
|
|
97
|
+
self._shell = shell
|
|
98
|
+
self._env = kwargs.get('env', [])
|
|
99
|
+
|
|
100
|
+
# Determine TTY mode: None = auto-detect, True = force, False = disable
|
|
101
|
+
if tty is None:
|
|
102
|
+
self._tty = sys.stdin.isatty()
|
|
103
|
+
else:
|
|
104
|
+
self._tty = tty
|
|
105
|
+
|
|
106
|
+
# Interactive state
|
|
107
|
+
self._old_tty_settings = None
|
|
108
|
+
self._io_task = None
|
|
109
|
+
self._execution = None
|
|
110
|
+
self._stdin = None
|
|
111
|
+
self._stdout = None
|
|
112
|
+
self._stderr = None
|
|
113
|
+
self._exited = None # Event to signal process exit
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def id(self) -> str:
|
|
117
|
+
"""Get the box ID."""
|
|
118
|
+
return self._box.id
|
|
119
|
+
|
|
120
|
+
async def __aenter__(self):
|
|
121
|
+
"""Start box and enter interactive TTY session."""
|
|
122
|
+
# Start box directly
|
|
123
|
+
await self._box.__aenter__()
|
|
124
|
+
|
|
125
|
+
# Start shell with PTY
|
|
126
|
+
self._execution = await self._start_interactive_shell()
|
|
127
|
+
|
|
128
|
+
# Get stdin/stdout/stderr ONCE (can only be called once due to .take())
|
|
129
|
+
self._stdin = self._execution.stdin()
|
|
130
|
+
self._stdout = self._execution.stdout()
|
|
131
|
+
self._stderr = self._execution.stderr()
|
|
132
|
+
|
|
133
|
+
# Only set cbreak mode and start forwarding if tty=True
|
|
134
|
+
if self._tty:
|
|
135
|
+
stdin_fd = sys.stdin.fileno()
|
|
136
|
+
self._old_tty_settings = termios.tcgetattr(stdin_fd)
|
|
137
|
+
tty.setraw(sys.stdin.fileno(), when=termios.TCSANOW)
|
|
138
|
+
|
|
139
|
+
# Create exit event for graceful shutdown
|
|
140
|
+
self._exited = asyncio.Event()
|
|
141
|
+
|
|
142
|
+
# Start bidirectional I/O forwarding using gather (more Pythonic)
|
|
143
|
+
self._io_task = asyncio.gather(
|
|
144
|
+
self._forward_stdin(),
|
|
145
|
+
self._forward_output(),
|
|
146
|
+
self._forward_stderr(),
|
|
147
|
+
self._wait_for_exit(),
|
|
148
|
+
return_exceptions=True
|
|
149
|
+
)
|
|
150
|
+
else:
|
|
151
|
+
# No I/O forwarding, just wait for execution
|
|
152
|
+
self._io_task = self._wait_for_exit()
|
|
153
|
+
|
|
154
|
+
return self
|
|
155
|
+
|
|
156
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
157
|
+
# Restore terminal settings
|
|
158
|
+
if self._old_tty_settings is not None:
|
|
159
|
+
try:
|
|
160
|
+
termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, self._old_tty_settings)
|
|
161
|
+
except Exception as e:
|
|
162
|
+
logger.error(f"Caught exception on TTY settings: {e}")
|
|
163
|
+
|
|
164
|
+
"""Exit interactive session and restore terminal."""
|
|
165
|
+
# Wait for I/O task to complete (or cancel if needed)
|
|
166
|
+
if hasattr(self, '_io_task') and self._io_task is not None:
|
|
167
|
+
try:
|
|
168
|
+
# Give it a moment to finish naturally
|
|
169
|
+
await asyncio.wait_for(self._io_task, timeout=3)
|
|
170
|
+
logger.info("Closing interactive shell (I/O tasks finished).")
|
|
171
|
+
self._io_task = None
|
|
172
|
+
except asyncio.TimeoutError:
|
|
173
|
+
# If it doesn't finish, that's ok - box is shutting down anyway
|
|
174
|
+
logger.error("Timeout waiting for I/O tasks to finish, cancelling...")
|
|
175
|
+
except Exception as e:
|
|
176
|
+
# Ignore other exceptions during cleanup
|
|
177
|
+
logger.error(f"Caught exception on exit: {e}")
|
|
178
|
+
|
|
179
|
+
# Shutdown box directly
|
|
180
|
+
return await self._box.__aexit__(exc_type, exc_val, exc_tb)
|
|
181
|
+
|
|
182
|
+
async def wait(self):
|
|
183
|
+
await self._execution.wait()
|
|
184
|
+
|
|
185
|
+
async def _start_interactive_shell(self):
|
|
186
|
+
"""Start shell with PTY (internal)."""
|
|
187
|
+
# Execute shell with PTY using simplified boolean API
|
|
188
|
+
# Terminal size is auto-detected (like Docker)
|
|
189
|
+
execution = await self._box.exec(
|
|
190
|
+
self._shell,
|
|
191
|
+
args=[],
|
|
192
|
+
env=self._env,
|
|
193
|
+
tty=True, # Simple boolean - auto-detects terminal size
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
return execution
|
|
197
|
+
|
|
198
|
+
async def _forward_stdin(self):
|
|
199
|
+
"""Forward stdin to PTY (internal)."""
|
|
200
|
+
try:
|
|
201
|
+
if self._stdin is None:
|
|
202
|
+
return
|
|
203
|
+
|
|
204
|
+
# Forward stdin in chunks
|
|
205
|
+
loop = asyncio.get_event_loop()
|
|
206
|
+
while not self._exited.is_set():
|
|
207
|
+
# Read from stdin with timeout to check exit event
|
|
208
|
+
try:
|
|
209
|
+
read_task = loop.run_in_executor(None, os.read, sys.stdin.fileno(), 1024)
|
|
210
|
+
# Wait for either stdin data or exit event
|
|
211
|
+
done, pending = await asyncio.wait(
|
|
212
|
+
[asyncio.ensure_future(read_task),
|
|
213
|
+
asyncio.ensure_future(self._exited.wait())],
|
|
214
|
+
return_when=asyncio.FIRST_COMPLETED
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
# Cancel pending tasks
|
|
218
|
+
for task in pending:
|
|
219
|
+
task.cancel()
|
|
220
|
+
|
|
221
|
+
# Check if we exited
|
|
222
|
+
if self._exited.is_set():
|
|
223
|
+
logger.info("Closing interactive shell (stdin forwarding).")
|
|
224
|
+
break
|
|
225
|
+
|
|
226
|
+
# Get the data from completed read task
|
|
227
|
+
for task in done:
|
|
228
|
+
if task.exception() is None:
|
|
229
|
+
data = task.result()
|
|
230
|
+
if isinstance(data, bytes) and data:
|
|
231
|
+
await self._stdin.send_input(data)
|
|
232
|
+
elif not data:
|
|
233
|
+
# EOF
|
|
234
|
+
return
|
|
235
|
+
|
|
236
|
+
except asyncio.CancelledError:
|
|
237
|
+
break
|
|
238
|
+
|
|
239
|
+
except asyncio.CancelledError:
|
|
240
|
+
logger.info("Cancelling interactive shell (stdin forwarding).")
|
|
241
|
+
except Exception as e:
|
|
242
|
+
logger.error(f"Caught exception on stdin: {e}")
|
|
243
|
+
|
|
244
|
+
async def _forward_output(self):
|
|
245
|
+
"""Forward PTY output to stdout (internal)."""
|
|
246
|
+
try:
|
|
247
|
+
if self._stdout is None:
|
|
248
|
+
return
|
|
249
|
+
|
|
250
|
+
# Forward all output to stdout
|
|
251
|
+
async for chunk in self._stdout:
|
|
252
|
+
# Write directly to stdout (bypass print buffering)
|
|
253
|
+
if isinstance(chunk, bytes):
|
|
254
|
+
sys.stdout.buffer.write(chunk)
|
|
255
|
+
else:
|
|
256
|
+
sys.stdout.buffer.write(chunk.encode('utf-8', errors='replace'))
|
|
257
|
+
sys.stdout.buffer.flush()
|
|
258
|
+
|
|
259
|
+
logger.info("\nOutput forwarding ended.")
|
|
260
|
+
|
|
261
|
+
except asyncio.CancelledError:
|
|
262
|
+
logger.error("Cancelling interactive shell (stdout forwarding).")
|
|
263
|
+
except Exception as e:
|
|
264
|
+
logger.error(f"\nError forwarding output: {e}", file=sys.stderr)
|
|
265
|
+
|
|
266
|
+
async def _forward_stderr(self):
|
|
267
|
+
"""Forward PTY stderr to stderr (internal)."""
|
|
268
|
+
try:
|
|
269
|
+
if self._stderr is None:
|
|
270
|
+
return
|
|
271
|
+
|
|
272
|
+
# Forward all error output to stderr
|
|
273
|
+
async for chunk in self._stderr:
|
|
274
|
+
# Write directly to stderr (bypass print buffering)
|
|
275
|
+
if isinstance(chunk, bytes):
|
|
276
|
+
sys.stderr.buffer.write(chunk)
|
|
277
|
+
else:
|
|
278
|
+
sys.stderr.buffer.write(chunk.encode('utf-8', errors='replace'))
|
|
279
|
+
sys.stderr.buffer.flush()
|
|
280
|
+
|
|
281
|
+
logger.info("\nStderr forwarding ended.")
|
|
282
|
+
|
|
283
|
+
except asyncio.CancelledError:
|
|
284
|
+
logger.error("Cancelling interactive shell (stderr forwarding).")
|
|
285
|
+
except Exception as e:
|
|
286
|
+
logger.error(f"\nError forwarding stderr: {e}", file=sys.stderr)
|
|
287
|
+
|
|
288
|
+
async def _wait_for_exit(self):
|
|
289
|
+
"""Wait for the shell to exit (internal)."""
|
|
290
|
+
try:
|
|
291
|
+
await self._execution.wait()
|
|
292
|
+
except Exception:
|
|
293
|
+
pass # Ignore errors, cleanup will happen in __aexit__
|
|
294
|
+
finally:
|
|
295
|
+
# Signal other tasks to stop
|
|
296
|
+
if self._exited:
|
|
297
|
+
self._exited.set()
|
|
Binary file
|
|
Binary file
|
boxlite/runtime/debugfs
ADDED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
boxlite/runtime/mke2fs
ADDED
|
Binary file
|
boxlite/simplebox.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SimpleBox - Foundation for specialized container types.
|
|
3
|
+
|
|
4
|
+
Provides common functionality for all specialized boxes (CodeBox, BrowserBox, etc.)
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from enum import IntEnum
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
from .exec import ExecResult
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger("boxlite.simplebox")
|
|
14
|
+
|
|
15
|
+
__all__ = ['SimpleBox']
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class StreamType(IntEnum):
|
|
19
|
+
"""Stream type for command execution output."""
|
|
20
|
+
STDOUT = 1
|
|
21
|
+
STDERR = 2
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SimpleBox:
|
|
25
|
+
"""
|
|
26
|
+
Base class for specialized container types.
|
|
27
|
+
|
|
28
|
+
This class encapsulates the common patterns:
|
|
29
|
+
1. Async context manager support
|
|
30
|
+
2. Automatic runtime lifecycle management
|
|
31
|
+
3. Stdio blocking mode restoration
|
|
32
|
+
|
|
33
|
+
Subclasses should override:
|
|
34
|
+
- _create_box_options(): Return BoxOptions for their specific use case
|
|
35
|
+
- Add domain-specific methods (e.g., CodeBox.run(), BrowserBox.navigate())
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
image: str,
|
|
41
|
+
memory_mib: Optional[int] = None,
|
|
42
|
+
cpus: Optional[int] = None,
|
|
43
|
+
runtime: Optional['Boxlite'] = None,
|
|
44
|
+
name: Optional[str] = None,
|
|
45
|
+
auto_remove: bool = True,
|
|
46
|
+
**kwargs
|
|
47
|
+
):
|
|
48
|
+
"""
|
|
49
|
+
Create a specialized box.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
image: Container images to use
|
|
53
|
+
memory_mib: Memory limit in MiB
|
|
54
|
+
cpus: Number of CPU cores
|
|
55
|
+
runtime: Optional runtime instance (uses global default if None)
|
|
56
|
+
name: Optional name for the box (must be unique)
|
|
57
|
+
auto_remove: Remove box when stopped (default: True)
|
|
58
|
+
**kwargs: Additional configuration options
|
|
59
|
+
"""
|
|
60
|
+
try:
|
|
61
|
+
from .boxlite import Boxlite
|
|
62
|
+
except ImportError as e:
|
|
63
|
+
raise ImportError(
|
|
64
|
+
f"BoxLite native extension not found: {e}. "
|
|
65
|
+
"Please install with: pip install boxlite"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# Use provided runtime or get Rust's global default
|
|
69
|
+
if runtime is None:
|
|
70
|
+
self._runtime = Boxlite.default()
|
|
71
|
+
else:
|
|
72
|
+
self._runtime = runtime
|
|
73
|
+
|
|
74
|
+
# Create box using subclass-defined options
|
|
75
|
+
try:
|
|
76
|
+
from .boxlite import BoxOptions
|
|
77
|
+
except ImportError as e:
|
|
78
|
+
raise ImportError(
|
|
79
|
+
f"BoxLite native extension not found: {e}. "
|
|
80
|
+
"Please install with: pip install boxlite"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
box_opts = BoxOptions(
|
|
84
|
+
image=image,
|
|
85
|
+
cpus=cpus,
|
|
86
|
+
memory_mib=memory_mib,
|
|
87
|
+
auto_remove=auto_remove,
|
|
88
|
+
**kwargs
|
|
89
|
+
)
|
|
90
|
+
self._name = name
|
|
91
|
+
self._box = self._runtime.create(box_opts, name=name)
|
|
92
|
+
|
|
93
|
+
async def __aenter__(self):
|
|
94
|
+
"""Async context manager entry - delegates to Box.__aenter__."""
|
|
95
|
+
await self._box.__aenter__()
|
|
96
|
+
return self
|
|
97
|
+
|
|
98
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
99
|
+
"""Async context manager exit - delegates to Box.__aexit__ (returns awaitable)."""
|
|
100
|
+
return await self._box.__aexit__(exc_type, exc_val, exc_tb)
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def id(self) -> str:
|
|
104
|
+
"""Get the box ID."""
|
|
105
|
+
return self._box.id
|
|
106
|
+
|
|
107
|
+
def info(self):
|
|
108
|
+
"""Get box information."""
|
|
109
|
+
return self._box.info()
|
|
110
|
+
|
|
111
|
+
async def exec(
|
|
112
|
+
self,
|
|
113
|
+
cmd: str,
|
|
114
|
+
*args: str,
|
|
115
|
+
env: Optional[dict[str, str]] = None,
|
|
116
|
+
) -> ExecResult:
|
|
117
|
+
"""
|
|
118
|
+
Execute a command in the box and return the result.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
cmd: Command to execute (e.g., 'ls', 'python')
|
|
122
|
+
*args: Arguments to the command (e.g., '-l', '-a')
|
|
123
|
+
env: Environment variables (default: guest's default environment)
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
ExecResult with exit_code and output
|
|
127
|
+
|
|
128
|
+
Examples:
|
|
129
|
+
Simple execution::
|
|
130
|
+
|
|
131
|
+
result = await box.exec('ls', '-l', '-a')
|
|
132
|
+
print(f"Exit code: {result.exit_code}")
|
|
133
|
+
print(f"Stdout: {result.stdout}")
|
|
134
|
+
print(f"Stderr: {result.stderr}")
|
|
135
|
+
|
|
136
|
+
With environment variables::
|
|
137
|
+
|
|
138
|
+
result = await box.exec('env', env={'FOO': 'bar'})
|
|
139
|
+
print(result.stdout)
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
arg_list = list(args) if args else None
|
|
143
|
+
# Convert env dict to list of tuples if provided
|
|
144
|
+
env_list = list(env.items()) if env else None
|
|
145
|
+
|
|
146
|
+
# Execute via Rust (returns PyExecution)
|
|
147
|
+
execution = await self._box.exec(cmd, arg_list, env_list)
|
|
148
|
+
|
|
149
|
+
# Get streams from Rust execution
|
|
150
|
+
try:
|
|
151
|
+
stdout = execution.stdout()
|
|
152
|
+
except Exception as e:
|
|
153
|
+
logger.error(f"take stdout err: {e}")
|
|
154
|
+
stdout = None
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
stderr = execution.stderr()
|
|
158
|
+
except Exception as e:
|
|
159
|
+
logger.error(f"take stderr err: {e}")
|
|
160
|
+
stderr = None
|
|
161
|
+
|
|
162
|
+
# Collect stdout and stderr separately
|
|
163
|
+
stdout_lines = []
|
|
164
|
+
stderr_lines = []
|
|
165
|
+
|
|
166
|
+
# Read stdout
|
|
167
|
+
if stdout:
|
|
168
|
+
logger.debug("collecting stdout")
|
|
169
|
+
try:
|
|
170
|
+
async for line in stdout:
|
|
171
|
+
if isinstance(line, bytes):
|
|
172
|
+
stdout_lines.append(line.decode('utf-8', errors='replace'))
|
|
173
|
+
else:
|
|
174
|
+
stdout_lines.append(line)
|
|
175
|
+
except Exception as e:
|
|
176
|
+
logger.error(f"collecting stdout err: {e}")
|
|
177
|
+
pass
|
|
178
|
+
|
|
179
|
+
# Read stderr
|
|
180
|
+
if stderr:
|
|
181
|
+
logger.debug("collecting stderr")
|
|
182
|
+
try:
|
|
183
|
+
async for line in stderr:
|
|
184
|
+
if isinstance(line, bytes):
|
|
185
|
+
stderr_lines.append(line.decode('utf-8', errors='replace'))
|
|
186
|
+
else:
|
|
187
|
+
stderr_lines.append(line)
|
|
188
|
+
except Exception as e:
|
|
189
|
+
logger.error(f"collecting stderr err: {e}")
|
|
190
|
+
pass
|
|
191
|
+
|
|
192
|
+
# Combine lines
|
|
193
|
+
stdout = ''.join(stdout_lines)
|
|
194
|
+
stderr = ''.join(stderr_lines)
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
exec_result = await execution.wait()
|
|
198
|
+
exit_code = exec_result.exit_code
|
|
199
|
+
except Exception as e:
|
|
200
|
+
logger.error(f"failed to wait execution: {e}")
|
|
201
|
+
exit_code = -1
|
|
202
|
+
|
|
203
|
+
logger.debug(f"exec finish, exit_code: {exit_code}")
|
|
204
|
+
|
|
205
|
+
return ExecResult(exit_code=exit_code, stdout=stdout, stderr=stderr)
|
|
206
|
+
|
|
207
|
+
def shutdown(self):
|
|
208
|
+
"""
|
|
209
|
+
Shutdown the box and release resources.
|
|
210
|
+
|
|
211
|
+
Note: Usually not needed as context manager handles cleanup.
|
|
212
|
+
"""
|
|
213
|
+
self._box.shutdown()
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: boxlite
|
|
3
|
+
Version: 0.4.0.dev0
|
|
4
|
+
Requires-Dist: pytest>=7.0 ; extra == 'dev'
|
|
5
|
+
Requires-Dist: pytest-asyncio>=0.21 ; extra == 'dev'
|
|
6
|
+
Provides-Extra: dev
|
|
7
|
+
Summary: Python bindings for Boxlite runtime
|
|
8
|
+
Author: Dorian Zheng
|
|
9
|
+
License-Expression: Apache-2.0
|
|
10
|
+
Requires-Python: >=3.10
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
boxlite-0.4.0.dev0.dist-info/METADATA,sha256=zW8vWdMNjl-XKLNWjJwEzRkHMc_QA8nXfDLU2lrnwmM,294
|
|
2
|
+
boxlite-0.4.0.dev0.dist-info/WHEEL,sha256=VaEj7o-foDEMacawJ0zlUcxv1TNSymopNT-bNjt0dO8,105
|
|
3
|
+
boxlite/__init__.py,sha256=2wYQEKJc86jXJIwWmIX1s1OiE3FcRs6k29NzCikcm74,1990
|
|
4
|
+
boxlite/boxlite.cpython-311-darwin.so,sha256=_fmnNuqeB7dGl2YIv3mdPFBcXxKCwDQhYAlkl9bfFUk,14824912
|
|
5
|
+
boxlite/browserbox.py,sha256=UX4yxZsnbFHgyLhS9-OC58AG1pecHpUwbxlO6leiV98,4477
|
|
6
|
+
boxlite/codebox.py,sha256=buppZbGh2_jd5syCXx-xFUMQWvo7DRAMXo5unEAgIZY,3677
|
|
7
|
+
boxlite/computerbox.py,sha256=bMFGyK2Gl0w3sw3TJmF6Cl5u2GlaMhSC4WVVbHMGsMg,10566
|
|
8
|
+
boxlite/constants.py,sha256=lIQkb5AHiqef_q9g23szDdzuCNpY8xBWSD0qD52Oc-U,615
|
|
9
|
+
boxlite/errors.py,sha256=vuW2uZvkrX9GGvE3SKIogO8r1rBxUplit_3F2CSxglg,957
|
|
10
|
+
boxlite/exec.py,sha256=UWoLBnKUPx93ueHC2E3KVU7evaj06TIb1pHhuShZwbM,507
|
|
11
|
+
boxlite/interactivebox.py,sha256=4Ffaf-u7m46bIRgHQ61qcLbmgWgUFkmQDLxXykzNy3g,10598
|
|
12
|
+
boxlite/runtime/boxlite-guest,sha256=oA6QuMwIKzrjR8h4fvCz3cjJ3o-nlig-vL7WrvoCSuo,11902344
|
|
13
|
+
boxlite/runtime/boxlite-shim,sha256=ywn1nEDsij6TtG_T9m82Zzw18DlYxDH-6Xu0RIqDsLk,3264976
|
|
14
|
+
boxlite/runtime/debugfs,sha256=DnAkd6bJsxf723TgoVhQOme1b3tZWCcCf5VLUWoY4sc,662328
|
|
15
|
+
boxlite/runtime/libgvproxy.dylib,sha256=irddG9i5EMEowq0rTIMfdWDdmp_m0qZ2zdHvf5lQH0s,10809824
|
|
16
|
+
boxlite/runtime/libkrun.1.15.1.dylib,sha256=jLbXTo2OKkBXZ_DkixypJ2yk0hcKFJeYL9y3globcgc,3960400
|
|
17
|
+
boxlite/runtime/libkrunfw.4.dylib,sha256=cRy63u5WT5twkeR2bHrRCZwcw7uidbeSs_bTCaKb-tA,21979472
|
|
18
|
+
boxlite/runtime/mke2fs,sha256=MscZ6oepZxGLq-sC7o5bLEwRkzzYFLKWhMsNr4T8rVY,578008
|
|
19
|
+
boxlite/simplebox.py,sha256=OIuoEhorcJMefc1_Bc-5RkWvytTg-0V-5joD9EZMqiQ,6497
|
|
20
|
+
boxlite-0.4.0.dev0.dist-info/RECORD,,
|