boxfab-desktop 0.1.0a1__tar.gz
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.
- boxfab_desktop-0.1.0a1/LICENSE +10 -0
- boxfab_desktop-0.1.0a1/PKG-INFO +290 -0
- boxfab_desktop-0.1.0a1/README.md +269 -0
- boxfab_desktop-0.1.0a1/boxfab_desktop/__init__.py +4 -0
- boxfab_desktop-0.1.0a1/boxfab_desktop/client.py +91 -0
- boxfab_desktop-0.1.0a1/boxfab_desktop/main.py +585 -0
- boxfab_desktop-0.1.0a1/pyproject.toml +49 -0
- boxfab_desktop-0.1.0a1/pyproject.toml.orig +50 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 FOUNDRYLABS, INC.
|
|
4
|
+
Copyright (c) 2026 ContextFab, Inc.
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
7
|
+
|
|
8
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
9
|
+
|
|
10
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: boxfab-desktop
|
|
3
|
+
Version: 0.1.0a1
|
|
4
|
+
Summary: BoxFab Desktop: a virtual Linux desktop in a BoxFab sandbox, for computer-use agents
|
|
5
|
+
Author: ContextFab, Inc.
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Requires-Dist: boxfab>=0.1.0a3,<0.2
|
|
15
|
+
Requires-Dist: requests>=2.32.3,<3
|
|
16
|
+
Requires-Dist: urllib3>=2.8.0,<3
|
|
17
|
+
Requires-Dist: pillow>=13.0.0,<14
|
|
18
|
+
Requires-Python: >=3.11
|
|
19
|
+
Project-URL: Repository, https://github.com/boxfab/python-sdk
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# BoxFab Desktop (Python)
|
|
23
|
+
|
|
24
|
+
A virtual Linux desktop in a [BoxFab](https://boxfab.io) sandbox, for computer-use agents: mouse
|
|
25
|
+
and keyboard control, screenshots, application windows, and a live stream of the screen in the
|
|
26
|
+
browser.
|
|
27
|
+
|
|
28
|
+
## Getting started
|
|
29
|
+
|
|
30
|
+
### 1. Get a BoxFab API key
|
|
31
|
+
|
|
32
|
+
Get a key from the [BoxFab dashboard](https://dashboard.boxfab.io) and set it as
|
|
33
|
+
`BOXFAB_API_KEY`. The SDK reads only `BOXFAB_*` variables.
|
|
34
|
+
|
|
35
|
+
### 2. Install the SDK
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install boxfab-desktop
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
> **Stream access.** `desktop.stream.start()` runs without a password unless you pass
|
|
42
|
+
> `require_auth=True`. The stream URL contains the sandbox ID, and anyone who has the URL can see
|
|
43
|
+
> and control the desktop. Pass `require_auth=True` whenever the URL leaves your backend; the auth
|
|
44
|
+
> key is then part of the URL returned by `get_url(auth_key=...)`, so share that URL only with the
|
|
45
|
+
> intended viewer.
|
|
46
|
+
|
|
47
|
+
### 3. Create Desktop Sandbox
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from boxfab_desktop import Sandbox
|
|
51
|
+
|
|
52
|
+
# Create a new desktop sandbox
|
|
53
|
+
desktop = Sandbox.create()
|
|
54
|
+
|
|
55
|
+
# Launch an application
|
|
56
|
+
desktop.launch('google-chrome') # or vscode, firefox, etc.
|
|
57
|
+
|
|
58
|
+
# Wait 10s for the application to open
|
|
59
|
+
desktop.wait(10000)
|
|
60
|
+
|
|
61
|
+
# Stream the application's window
|
|
62
|
+
# Note: There can be only one stream at a time
|
|
63
|
+
# You need to stop the current stream before streaming another application
|
|
64
|
+
desktop.stream.start(
|
|
65
|
+
window_id=desktop.get_current_window_id(), # if not provided the whole desktop will be streamed
|
|
66
|
+
require_auth=True
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# Get the stream auth key
|
|
70
|
+
auth_key = desktop.stream.get_auth_key()
|
|
71
|
+
|
|
72
|
+
# Print the stream URL
|
|
73
|
+
print('Stream URL:', desktop.stream.get_url(auth_key=auth_key))
|
|
74
|
+
|
|
75
|
+
# Kill the sandbox after the tasks are finished
|
|
76
|
+
# desktop.kill()
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### 4. Bind the configuration to a client
|
|
80
|
+
|
|
81
|
+
The top-level `Sandbox` export reads its configuration from the environment variables. To use an explicit configuration — e.g. several API keys or domains in one process — create a `BoxFab` client and use the resource classes it exposes:
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
from boxfab_desktop import BoxFab
|
|
85
|
+
|
|
86
|
+
client = BoxFab(api_key="e2b_***", domain="boxfab.io")
|
|
87
|
+
|
|
88
|
+
desktop = client.Sandbox.create()
|
|
89
|
+
desktop.stream.start()
|
|
90
|
+
|
|
91
|
+
# The core resources are bound to the client's configuration as well.
|
|
92
|
+
volume = client.Volume.create("my-volume")
|
|
93
|
+
exists = client.Template.exists("my-template")
|
|
94
|
+
secret = client.Secret.create("openai-api-key", "sk-***")
|
|
95
|
+
|
|
96
|
+
# The classes can be assigned and used like the top-level ones.
|
|
97
|
+
Sandbox = client.Sandbox
|
|
98
|
+
paginator = Sandbox.list()
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Per-call params still take precedence over the client's params, and clients are isolated from each other and from the env-configured top-level exports.
|
|
102
|
+
|
|
103
|
+
## Features
|
|
104
|
+
|
|
105
|
+
### Streaming desktop's screen
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
from boxfab_desktop import Sandbox
|
|
109
|
+
desktop = Sandbox.create()
|
|
110
|
+
|
|
111
|
+
# Start the stream
|
|
112
|
+
desktop.stream.start()
|
|
113
|
+
|
|
114
|
+
# Get stream URL
|
|
115
|
+
url = desktop.stream.get_url()
|
|
116
|
+
print(url)
|
|
117
|
+
|
|
118
|
+
# Stop the stream
|
|
119
|
+
desktop.stream.stop()
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### Streaming with password protection
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
from boxfab_desktop import Sandbox
|
|
126
|
+
desktop = Sandbox.create()
|
|
127
|
+
|
|
128
|
+
# Start the stream
|
|
129
|
+
desktop.stream.start(
|
|
130
|
+
require_auth=True # Enable authentication with an auto-generated key
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
# Retrieve the authentication key
|
|
134
|
+
auth_key = desktop.stream.get_auth_key()
|
|
135
|
+
|
|
136
|
+
# Get stream URL
|
|
137
|
+
url = desktop.stream.get_url(auth_key=auth_key)
|
|
138
|
+
print(url)
|
|
139
|
+
|
|
140
|
+
# Stop the stream
|
|
141
|
+
desktop.stream.stop()
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
### Streaming specific application
|
|
145
|
+
|
|
146
|
+
> [!WARNING]
|
|
147
|
+
>
|
|
148
|
+
> - Will raise an error if the desired application is not open yet
|
|
149
|
+
> - The stream will close once the application closes
|
|
150
|
+
> - Creating multiple streams at the same time is not supported, you may have to stop the current stream and start a new one for each application
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
from boxfab_desktop import Sandbox
|
|
154
|
+
desktop = Sandbox.create()
|
|
155
|
+
|
|
156
|
+
# Get current (active) window ID
|
|
157
|
+
window_id = desktop.get_current_window_id()
|
|
158
|
+
|
|
159
|
+
# Get all windows of the application
|
|
160
|
+
window_ids = desktop.get_application_windows("Firefox")
|
|
161
|
+
|
|
162
|
+
# Start the stream
|
|
163
|
+
desktop.stream.start(window_id=window_ids[0])
|
|
164
|
+
|
|
165
|
+
# Stop the stream
|
|
166
|
+
desktop.stream.stop()
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### Mouse control
|
|
170
|
+
|
|
171
|
+
```python
|
|
172
|
+
from boxfab_desktop import Sandbox
|
|
173
|
+
desktop = Sandbox.create()
|
|
174
|
+
|
|
175
|
+
desktop.double_click()
|
|
176
|
+
desktop.left_click()
|
|
177
|
+
desktop.left_click(x=100, y=200)
|
|
178
|
+
desktop.right_click()
|
|
179
|
+
desktop.right_click(x=100, y=200)
|
|
180
|
+
desktop.middle_click()
|
|
181
|
+
desktop.middle_click(x=100, y=200)
|
|
182
|
+
desktop.scroll(10) # Scroll by the amount. Positive for up, negative for down.
|
|
183
|
+
desktop.move_mouse(100, 200) # Move to x, y coordinates
|
|
184
|
+
desktop.drag((100, 100), (200, 200)) # Drag using the mouse
|
|
185
|
+
desktop.mouse_press("left") # Press the mouse button
|
|
186
|
+
desktop.mouse_release("left") # Release the mouse button
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
### Keyboard control
|
|
190
|
+
|
|
191
|
+
```python
|
|
192
|
+
from boxfab_desktop import Sandbox
|
|
193
|
+
desktop = Sandbox.create()
|
|
194
|
+
|
|
195
|
+
# Write text at the current cursor position with customizable typing speed
|
|
196
|
+
desktop.write("Hello, world!") # Default: chunk_size=25, delay_in_ms=75
|
|
197
|
+
desktop.write("Fast typing!", chunk_size=50, delay_in_ms=25) # Faster typing
|
|
198
|
+
|
|
199
|
+
# Press keys
|
|
200
|
+
desktop.press("enter")
|
|
201
|
+
desktop.press("space")
|
|
202
|
+
desktop.press("backspace")
|
|
203
|
+
desktop.press(["ctrl", "c"]) # Key combination
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
### Window control
|
|
207
|
+
|
|
208
|
+
```python
|
|
209
|
+
from boxfab_desktop import Sandbox
|
|
210
|
+
desktop = Sandbox.create()
|
|
211
|
+
|
|
212
|
+
# Get current (active) window ID
|
|
213
|
+
window_id = desktop.get_current_window_id()
|
|
214
|
+
|
|
215
|
+
# Get all windows of the application
|
|
216
|
+
window_ids = desktop.get_application_windows("Firefox")
|
|
217
|
+
|
|
218
|
+
# Get window title
|
|
219
|
+
title = desktop.get_window_title(window_id)
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
### Screenshot
|
|
223
|
+
|
|
224
|
+
```python
|
|
225
|
+
from boxfab_desktop import Sandbox
|
|
226
|
+
desktop = Sandbox.create()
|
|
227
|
+
|
|
228
|
+
# Take a screenshot and save it as "screenshot.png" locally
|
|
229
|
+
image = desktop.screenshot()
|
|
230
|
+
# Save the image to a file
|
|
231
|
+
with open("screenshot.png", "wb") as f:
|
|
232
|
+
f.write(image)
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
### Open file
|
|
236
|
+
|
|
237
|
+
```python
|
|
238
|
+
from boxfab_desktop import Sandbox
|
|
239
|
+
desktop = Sandbox.create()
|
|
240
|
+
|
|
241
|
+
# Open file with default application
|
|
242
|
+
desktop.files.write("/home/user/index.js", "console.log('hello')") # First create the file
|
|
243
|
+
desktop.open("/home/user/index.js") # Then open it
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
### Launch applications
|
|
247
|
+
|
|
248
|
+
```python
|
|
249
|
+
from boxfab_desktop import Sandbox
|
|
250
|
+
desktop = Sandbox.create()
|
|
251
|
+
|
|
252
|
+
# Launch the application
|
|
253
|
+
desktop.launch('google-chrome')
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
### Run any bash commands
|
|
257
|
+
|
|
258
|
+
```python
|
|
259
|
+
from boxfab_desktop import Sandbox
|
|
260
|
+
desktop = Sandbox.create()
|
|
261
|
+
|
|
262
|
+
# Run any bash command
|
|
263
|
+
out = desktop.commands.run("ls -la /home/user")
|
|
264
|
+
print(out)
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
### Wait
|
|
268
|
+
|
|
269
|
+
```python
|
|
270
|
+
from boxfab_desktop import Sandbox
|
|
271
|
+
desktop = Sandbox.create()
|
|
272
|
+
|
|
273
|
+
desktop.wait(1000) # Wait for 1 second
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
## Under the hood
|
|
277
|
+
|
|
278
|
+
The desktop-like environment is based on Linux and [Xfce](https://www.xfce.org/) at the moment. We chose Xfce because it's a fast and lightweight environment that's also popular and actively supported. However, this Sandbox template is fully customizable and you can create your own desktop environment.
|
|
279
|
+
BoxFab's template is in [boxfab/desktop](https://github.com/boxfab/desktop/tree/main/template), generated from [e2b-dev/desktop](https://github.com/e2b-dev/desktop/tree/main/template).
|
|
280
|
+
|
|
281
|
+
BoxFab sandboxes are [Firecracker](https://firecracker-microvm.github.io/) microVMs, run on the
|
|
282
|
+
open-source [E2B](https://github.com/e2b-dev/infra) sandbox infrastructure. This package is derived
|
|
283
|
+
from the open-source E2B Desktop SDK;
|
|
284
|
+
[UPSTREAM.md](https://github.com/boxfab/python-sdk/blob/main/UPSTREAM.md) lists every change.
|
|
285
|
+
|
|
286
|
+
## License
|
|
287
|
+
|
|
288
|
+
MIT, as the E2B Desktop SDK it is derived from; see [LICENSE](LICENSE).
|
|
289
|
+
|
|
290
|
+
BoxFab is a product of ContextFab, Inc.
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
# BoxFab Desktop (Python)
|
|
2
|
+
|
|
3
|
+
A virtual Linux desktop in a [BoxFab](https://boxfab.io) sandbox, for computer-use agents: mouse
|
|
4
|
+
and keyboard control, screenshots, application windows, and a live stream of the screen in the
|
|
5
|
+
browser.
|
|
6
|
+
|
|
7
|
+
## Getting started
|
|
8
|
+
|
|
9
|
+
### 1. Get a BoxFab API key
|
|
10
|
+
|
|
11
|
+
Get a key from the [BoxFab dashboard](https://dashboard.boxfab.io) and set it as
|
|
12
|
+
`BOXFAB_API_KEY`. The SDK reads only `BOXFAB_*` variables.
|
|
13
|
+
|
|
14
|
+
### 2. Install the SDK
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pip install boxfab-desktop
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
> **Stream access.** `desktop.stream.start()` runs without a password unless you pass
|
|
21
|
+
> `require_auth=True`. The stream URL contains the sandbox ID, and anyone who has the URL can see
|
|
22
|
+
> and control the desktop. Pass `require_auth=True` whenever the URL leaves your backend; the auth
|
|
23
|
+
> key is then part of the URL returned by `get_url(auth_key=...)`, so share that URL only with the
|
|
24
|
+
> intended viewer.
|
|
25
|
+
|
|
26
|
+
### 3. Create Desktop Sandbox
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from boxfab_desktop import Sandbox
|
|
30
|
+
|
|
31
|
+
# Create a new desktop sandbox
|
|
32
|
+
desktop = Sandbox.create()
|
|
33
|
+
|
|
34
|
+
# Launch an application
|
|
35
|
+
desktop.launch('google-chrome') # or vscode, firefox, etc.
|
|
36
|
+
|
|
37
|
+
# Wait 10s for the application to open
|
|
38
|
+
desktop.wait(10000)
|
|
39
|
+
|
|
40
|
+
# Stream the application's window
|
|
41
|
+
# Note: There can be only one stream at a time
|
|
42
|
+
# You need to stop the current stream before streaming another application
|
|
43
|
+
desktop.stream.start(
|
|
44
|
+
window_id=desktop.get_current_window_id(), # if not provided the whole desktop will be streamed
|
|
45
|
+
require_auth=True
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# Get the stream auth key
|
|
49
|
+
auth_key = desktop.stream.get_auth_key()
|
|
50
|
+
|
|
51
|
+
# Print the stream URL
|
|
52
|
+
print('Stream URL:', desktop.stream.get_url(auth_key=auth_key))
|
|
53
|
+
|
|
54
|
+
# Kill the sandbox after the tasks are finished
|
|
55
|
+
# desktop.kill()
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### 4. Bind the configuration to a client
|
|
59
|
+
|
|
60
|
+
The top-level `Sandbox` export reads its configuration from the environment variables. To use an explicit configuration — e.g. several API keys or domains in one process — create a `BoxFab` client and use the resource classes it exposes:
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
from boxfab_desktop import BoxFab
|
|
64
|
+
|
|
65
|
+
client = BoxFab(api_key="e2b_***", domain="boxfab.io")
|
|
66
|
+
|
|
67
|
+
desktop = client.Sandbox.create()
|
|
68
|
+
desktop.stream.start()
|
|
69
|
+
|
|
70
|
+
# The core resources are bound to the client's configuration as well.
|
|
71
|
+
volume = client.Volume.create("my-volume")
|
|
72
|
+
exists = client.Template.exists("my-template")
|
|
73
|
+
secret = client.Secret.create("openai-api-key", "sk-***")
|
|
74
|
+
|
|
75
|
+
# The classes can be assigned and used like the top-level ones.
|
|
76
|
+
Sandbox = client.Sandbox
|
|
77
|
+
paginator = Sandbox.list()
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Per-call params still take precedence over the client's params, and clients are isolated from each other and from the env-configured top-level exports.
|
|
81
|
+
|
|
82
|
+
## Features
|
|
83
|
+
|
|
84
|
+
### Streaming desktop's screen
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from boxfab_desktop import Sandbox
|
|
88
|
+
desktop = Sandbox.create()
|
|
89
|
+
|
|
90
|
+
# Start the stream
|
|
91
|
+
desktop.stream.start()
|
|
92
|
+
|
|
93
|
+
# Get stream URL
|
|
94
|
+
url = desktop.stream.get_url()
|
|
95
|
+
print(url)
|
|
96
|
+
|
|
97
|
+
# Stop the stream
|
|
98
|
+
desktop.stream.stop()
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Streaming with password protection
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
from boxfab_desktop import Sandbox
|
|
105
|
+
desktop = Sandbox.create()
|
|
106
|
+
|
|
107
|
+
# Start the stream
|
|
108
|
+
desktop.stream.start(
|
|
109
|
+
require_auth=True # Enable authentication with an auto-generated key
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# Retrieve the authentication key
|
|
113
|
+
auth_key = desktop.stream.get_auth_key()
|
|
114
|
+
|
|
115
|
+
# Get stream URL
|
|
116
|
+
url = desktop.stream.get_url(auth_key=auth_key)
|
|
117
|
+
print(url)
|
|
118
|
+
|
|
119
|
+
# Stop the stream
|
|
120
|
+
desktop.stream.stop()
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### Streaming specific application
|
|
124
|
+
|
|
125
|
+
> [!WARNING]
|
|
126
|
+
>
|
|
127
|
+
> - Will raise an error if the desired application is not open yet
|
|
128
|
+
> - The stream will close once the application closes
|
|
129
|
+
> - Creating multiple streams at the same time is not supported, you may have to stop the current stream and start a new one for each application
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
from boxfab_desktop import Sandbox
|
|
133
|
+
desktop = Sandbox.create()
|
|
134
|
+
|
|
135
|
+
# Get current (active) window ID
|
|
136
|
+
window_id = desktop.get_current_window_id()
|
|
137
|
+
|
|
138
|
+
# Get all windows of the application
|
|
139
|
+
window_ids = desktop.get_application_windows("Firefox")
|
|
140
|
+
|
|
141
|
+
# Start the stream
|
|
142
|
+
desktop.stream.start(window_id=window_ids[0])
|
|
143
|
+
|
|
144
|
+
# Stop the stream
|
|
145
|
+
desktop.stream.stop()
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### Mouse control
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
from boxfab_desktop import Sandbox
|
|
152
|
+
desktop = Sandbox.create()
|
|
153
|
+
|
|
154
|
+
desktop.double_click()
|
|
155
|
+
desktop.left_click()
|
|
156
|
+
desktop.left_click(x=100, y=200)
|
|
157
|
+
desktop.right_click()
|
|
158
|
+
desktop.right_click(x=100, y=200)
|
|
159
|
+
desktop.middle_click()
|
|
160
|
+
desktop.middle_click(x=100, y=200)
|
|
161
|
+
desktop.scroll(10) # Scroll by the amount. Positive for up, negative for down.
|
|
162
|
+
desktop.move_mouse(100, 200) # Move to x, y coordinates
|
|
163
|
+
desktop.drag((100, 100), (200, 200)) # Drag using the mouse
|
|
164
|
+
desktop.mouse_press("left") # Press the mouse button
|
|
165
|
+
desktop.mouse_release("left") # Release the mouse button
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### Keyboard control
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
from boxfab_desktop import Sandbox
|
|
172
|
+
desktop = Sandbox.create()
|
|
173
|
+
|
|
174
|
+
# Write text at the current cursor position with customizable typing speed
|
|
175
|
+
desktop.write("Hello, world!") # Default: chunk_size=25, delay_in_ms=75
|
|
176
|
+
desktop.write("Fast typing!", chunk_size=50, delay_in_ms=25) # Faster typing
|
|
177
|
+
|
|
178
|
+
# Press keys
|
|
179
|
+
desktop.press("enter")
|
|
180
|
+
desktop.press("space")
|
|
181
|
+
desktop.press("backspace")
|
|
182
|
+
desktop.press(["ctrl", "c"]) # Key combination
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
### Window control
|
|
186
|
+
|
|
187
|
+
```python
|
|
188
|
+
from boxfab_desktop import Sandbox
|
|
189
|
+
desktop = Sandbox.create()
|
|
190
|
+
|
|
191
|
+
# Get current (active) window ID
|
|
192
|
+
window_id = desktop.get_current_window_id()
|
|
193
|
+
|
|
194
|
+
# Get all windows of the application
|
|
195
|
+
window_ids = desktop.get_application_windows("Firefox")
|
|
196
|
+
|
|
197
|
+
# Get window title
|
|
198
|
+
title = desktop.get_window_title(window_id)
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
### Screenshot
|
|
202
|
+
|
|
203
|
+
```python
|
|
204
|
+
from boxfab_desktop import Sandbox
|
|
205
|
+
desktop = Sandbox.create()
|
|
206
|
+
|
|
207
|
+
# Take a screenshot and save it as "screenshot.png" locally
|
|
208
|
+
image = desktop.screenshot()
|
|
209
|
+
# Save the image to a file
|
|
210
|
+
with open("screenshot.png", "wb") as f:
|
|
211
|
+
f.write(image)
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
### Open file
|
|
215
|
+
|
|
216
|
+
```python
|
|
217
|
+
from boxfab_desktop import Sandbox
|
|
218
|
+
desktop = Sandbox.create()
|
|
219
|
+
|
|
220
|
+
# Open file with default application
|
|
221
|
+
desktop.files.write("/home/user/index.js", "console.log('hello')") # First create the file
|
|
222
|
+
desktop.open("/home/user/index.js") # Then open it
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
### Launch applications
|
|
226
|
+
|
|
227
|
+
```python
|
|
228
|
+
from boxfab_desktop import Sandbox
|
|
229
|
+
desktop = Sandbox.create()
|
|
230
|
+
|
|
231
|
+
# Launch the application
|
|
232
|
+
desktop.launch('google-chrome')
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
### Run any bash commands
|
|
236
|
+
|
|
237
|
+
```python
|
|
238
|
+
from boxfab_desktop import Sandbox
|
|
239
|
+
desktop = Sandbox.create()
|
|
240
|
+
|
|
241
|
+
# Run any bash command
|
|
242
|
+
out = desktop.commands.run("ls -la /home/user")
|
|
243
|
+
print(out)
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
### Wait
|
|
247
|
+
|
|
248
|
+
```python
|
|
249
|
+
from boxfab_desktop import Sandbox
|
|
250
|
+
desktop = Sandbox.create()
|
|
251
|
+
|
|
252
|
+
desktop.wait(1000) # Wait for 1 second
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
## Under the hood
|
|
256
|
+
|
|
257
|
+
The desktop-like environment is based on Linux and [Xfce](https://www.xfce.org/) at the moment. We chose Xfce because it's a fast and lightweight environment that's also popular and actively supported. However, this Sandbox template is fully customizable and you can create your own desktop environment.
|
|
258
|
+
BoxFab's template is in [boxfab/desktop](https://github.com/boxfab/desktop/tree/main/template), generated from [e2b-dev/desktop](https://github.com/e2b-dev/desktop/tree/main/template).
|
|
259
|
+
|
|
260
|
+
BoxFab sandboxes are [Firecracker](https://firecracker-microvm.github.io/) microVMs, run on the
|
|
261
|
+
open-source [E2B](https://github.com/e2b-dev/infra) sandbox infrastructure. This package is derived
|
|
262
|
+
from the open-source E2B Desktop SDK;
|
|
263
|
+
[UPSTREAM.md](https://github.com/boxfab/python-sdk/blob/main/UPSTREAM.md) lists every change.
|
|
264
|
+
|
|
265
|
+
## License
|
|
266
|
+
|
|
267
|
+
MIT, as the E2B Desktop SDK it is derived from; see [LICENSE](LICENSE).
|
|
268
|
+
|
|
269
|
+
BoxFab is a product of ContextFab, Inc.
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
from typing import Dict, Type, TypeVar, Unpack, cast
|
|
2
|
+
|
|
3
|
+
from boxfab import ApiParams, BoxFabClientParams, E2BClientParams
|
|
4
|
+
from boxfab import BoxFab as CoreBoxFab
|
|
5
|
+
from boxfab_desktop.main import Sandbox
|
|
6
|
+
|
|
7
|
+
T = TypeVar("T")
|
|
8
|
+
|
|
9
|
+
__all__ = ["BoxFab", "BoxFabClientParams", "E2B", "E2BClientParams"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _bind(cls: Type[T], api_params: ApiParams) -> Type[T]:
|
|
13
|
+
"""Generate a subclass of ``cls`` carrying ``api_params`` as its bound params."""
|
|
14
|
+
return cast(
|
|
15
|
+
Type[T],
|
|
16
|
+
type(cls.__name__, (cls,), {"_bound_api_params": api_params}),
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class BoxFab:
|
|
21
|
+
"""
|
|
22
|
+
BoxFab client with an explicitly bound connection configuration.
|
|
23
|
+
|
|
24
|
+
The resource classes exposed by the client (`Sandbox`, `Volume`,
|
|
25
|
+
`AsyncVolume`, `Template`, `AsyncTemplate`, `Secret`, `AsyncSecret`) behave
|
|
26
|
+
exactly like the top-level exports of the same name, except the params
|
|
27
|
+
passed to the client are used as the defaults instead of the environment
|
|
28
|
+
variables.
|
|
29
|
+
Per-call params still take precedence over the client's params.
|
|
30
|
+
|
|
31
|
+
Multiple clients are fully isolated from each other and from the top-level
|
|
32
|
+
env-configured exports.
|
|
33
|
+
|
|
34
|
+
Example:
|
|
35
|
+
```python
|
|
36
|
+
from boxfab_desktop import BoxFab
|
|
37
|
+
|
|
38
|
+
client = BoxFab(api_key="e2b_...", domain="boxfab.io")
|
|
39
|
+
|
|
40
|
+
desktop = client.Sandbox.create()
|
|
41
|
+
desktop.stream.start()
|
|
42
|
+
```
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(self, **opts: Unpack[BoxFabClientParams]):
|
|
46
|
+
"""
|
|
47
|
+
Create a new client with the API params bound to it.
|
|
48
|
+
|
|
49
|
+
:param opts: API params used as the defaults for every call made
|
|
50
|
+
through this client's resource classes.
|
|
51
|
+
"""
|
|
52
|
+
# Params are copied so later mutations of the caller's dicts cannot
|
|
53
|
+
# change the bound configuration.
|
|
54
|
+
api_params = cast(ApiParams, dict(cast(Dict[str, object], opts)))
|
|
55
|
+
|
|
56
|
+
headers = api_params.get("headers")
|
|
57
|
+
if headers is not None:
|
|
58
|
+
api_params["headers"] = dict(headers)
|
|
59
|
+
|
|
60
|
+
api_headers = api_params.get("api_headers")
|
|
61
|
+
if api_headers is not None:
|
|
62
|
+
api_params["api_headers"] = dict(api_headers)
|
|
63
|
+
|
|
64
|
+
self.Sandbox = _bind(Sandbox, api_params)
|
|
65
|
+
"""Desktop `Sandbox` class bound to this client's connection configuration."""
|
|
66
|
+
|
|
67
|
+
# The resources that are not specific to the Desktop are bound by the
|
|
68
|
+
# core client.
|
|
69
|
+
core = CoreBoxFab(**api_params)
|
|
70
|
+
|
|
71
|
+
self.Volume = core.Volume
|
|
72
|
+
"""`Volume` class bound to this client's connection configuration."""
|
|
73
|
+
|
|
74
|
+
self.AsyncVolume = core.AsyncVolume
|
|
75
|
+
"""`AsyncVolume` class bound to this client's connection configuration."""
|
|
76
|
+
|
|
77
|
+
self.Template = core.Template
|
|
78
|
+
"""`Template` class bound to this client's connection configuration."""
|
|
79
|
+
|
|
80
|
+
self.AsyncTemplate = core.AsyncTemplate
|
|
81
|
+
"""`AsyncTemplate` class bound to this client's connection configuration."""
|
|
82
|
+
|
|
83
|
+
self.Secret = core.Secret
|
|
84
|
+
"""`Secret` class bound to this client's connection configuration."""
|
|
85
|
+
|
|
86
|
+
self.AsyncSecret = core.AsyncSecret
|
|
87
|
+
"""`AsyncSecret` class bound to this client's connection configuration."""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# Upstream name, kept so code written for the upstream package ports by changing only the import.
|
|
91
|
+
E2B = BoxFab
|
|
@@ -0,0 +1,585 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import time
|
|
3
|
+
from re import search as re_search
|
|
4
|
+
from shlex import quote as quote_string
|
|
5
|
+
from typing import (
|
|
6
|
+
Callable,
|
|
7
|
+
Dict,
|
|
8
|
+
Iterator,
|
|
9
|
+
Literal,
|
|
10
|
+
Optional,
|
|
11
|
+
Self,
|
|
12
|
+
Unpack,
|
|
13
|
+
overload,
|
|
14
|
+
Tuple,
|
|
15
|
+
Union,
|
|
16
|
+
)
|
|
17
|
+
from uuid import uuid4
|
|
18
|
+
|
|
19
|
+
from boxfab import (
|
|
20
|
+
Sandbox as SandboxBase,
|
|
21
|
+
CommandHandle,
|
|
22
|
+
CommandResult,
|
|
23
|
+
TimeoutException,
|
|
24
|
+
CommandExitException,
|
|
25
|
+
SandboxIamOpts,
|
|
26
|
+
SandboxLifecycle,
|
|
27
|
+
SandboxNetworkOpts,
|
|
28
|
+
Volume,
|
|
29
|
+
)
|
|
30
|
+
from boxfab.connection_config import ApiParams
|
|
31
|
+
|
|
32
|
+
MOUSE_BUTTONS = {"left": 1, "right": 3, "middle": 2}
|
|
33
|
+
|
|
34
|
+
KEYS = {
|
|
35
|
+
"alt": "Alt_L",
|
|
36
|
+
"alt_left": "Alt_L",
|
|
37
|
+
"alt_right": "Alt_R",
|
|
38
|
+
"backspace": "BackSpace",
|
|
39
|
+
"break": "Pause",
|
|
40
|
+
"caps_lock": "Caps_Lock",
|
|
41
|
+
"cmd": "Super_L",
|
|
42
|
+
"command": "Super_L",
|
|
43
|
+
"control": "Control_L",
|
|
44
|
+
"control_left": "Control_L",
|
|
45
|
+
"control_right": "Control_R",
|
|
46
|
+
"ctrl": "Control_L",
|
|
47
|
+
"del": "Delete",
|
|
48
|
+
"delete": "Delete",
|
|
49
|
+
"down": "Down",
|
|
50
|
+
"end": "End",
|
|
51
|
+
"enter": "Return",
|
|
52
|
+
"esc": "Escape",
|
|
53
|
+
"escape": "Escape",
|
|
54
|
+
"f1": "F1",
|
|
55
|
+
"f2": "F2",
|
|
56
|
+
"f3": "F3",
|
|
57
|
+
"f4": "F4",
|
|
58
|
+
"f5": "F5",
|
|
59
|
+
"f6": "F6",
|
|
60
|
+
"f7": "F7",
|
|
61
|
+
"f8": "F8",
|
|
62
|
+
"f9": "F9",
|
|
63
|
+
"f10": "F10",
|
|
64
|
+
"f11": "F11",
|
|
65
|
+
"f12": "F12",
|
|
66
|
+
"home": "Home",
|
|
67
|
+
"insert": "Insert",
|
|
68
|
+
"left": "Left",
|
|
69
|
+
"menu": "Menu",
|
|
70
|
+
"meta": "Meta_L",
|
|
71
|
+
"num_lock": "Num_Lock",
|
|
72
|
+
"page_down": "Page_Down",
|
|
73
|
+
"page_up": "Page_Up",
|
|
74
|
+
"pause": "Pause",
|
|
75
|
+
"print": "Print",
|
|
76
|
+
"right": "Right",
|
|
77
|
+
"scroll_lock": "Scroll_Lock",
|
|
78
|
+
"shift": "Shift_L",
|
|
79
|
+
"shift_left": "Shift_L",
|
|
80
|
+
"shift_right": "Shift_R",
|
|
81
|
+
"space": "space",
|
|
82
|
+
"super": "Super_L",
|
|
83
|
+
"super_left": "Super_L",
|
|
84
|
+
"super_right": "Super_R",
|
|
85
|
+
"tab": "Tab",
|
|
86
|
+
"up": "Up",
|
|
87
|
+
"win": "Super_L",
|
|
88
|
+
"windows": "Super_L",
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def map_key(key: str) -> str:
|
|
93
|
+
lower_key = key.lower()
|
|
94
|
+
if lower_key in KEYS:
|
|
95
|
+
return KEYS[lower_key]
|
|
96
|
+
return lower_key
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class _VNCServer:
|
|
100
|
+
def __init__(self, desktop: "Sandbox") -> None:
|
|
101
|
+
self.__novnc_handle: Optional[CommandHandle] = None
|
|
102
|
+
|
|
103
|
+
self._vnc_port = 5900
|
|
104
|
+
self._port = 6080
|
|
105
|
+
self._novnc_auth_enabled = False
|
|
106
|
+
self._novnc_password = None
|
|
107
|
+
|
|
108
|
+
self._url = f"https://{desktop.get_host(self._port)}/vnc.html"
|
|
109
|
+
|
|
110
|
+
self.__desktop = desktop
|
|
111
|
+
|
|
112
|
+
def _wait_for_port(self, port: int) -> bool:
|
|
113
|
+
return self.__desktop._wait_and_verify(
|
|
114
|
+
f'netstat -tuln | grep ":{port} "', lambda r: r.stdout.strip() != ""
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
def _check_vnc_running(self) -> bool:
|
|
118
|
+
try:
|
|
119
|
+
self.__desktop.commands.run("pgrep -x x11vnc")
|
|
120
|
+
return True
|
|
121
|
+
except CommandExitException:
|
|
122
|
+
return False
|
|
123
|
+
|
|
124
|
+
@staticmethod
|
|
125
|
+
def _generate_password(length: int = 16) -> str:
|
|
126
|
+
import secrets
|
|
127
|
+
import string
|
|
128
|
+
|
|
129
|
+
characters = string.ascii_letters + string.digits
|
|
130
|
+
return "".join(secrets.choice(characters) for _ in range(length))
|
|
131
|
+
|
|
132
|
+
def get_url(
|
|
133
|
+
self,
|
|
134
|
+
auto_connect: bool = True,
|
|
135
|
+
view_only: bool = False,
|
|
136
|
+
resize: str = "scale",
|
|
137
|
+
auth_key: Optional[str] = None,
|
|
138
|
+
) -> str:
|
|
139
|
+
params = []
|
|
140
|
+
if auto_connect:
|
|
141
|
+
params.append("autoconnect=true")
|
|
142
|
+
if view_only:
|
|
143
|
+
params.append("view_only=true")
|
|
144
|
+
if resize:
|
|
145
|
+
params.append(f"resize={resize}")
|
|
146
|
+
if auth_key:
|
|
147
|
+
params.append(f"password={auth_key}")
|
|
148
|
+
if params:
|
|
149
|
+
return f"{self._url}?{'&'.join(params)}"
|
|
150
|
+
return self._url
|
|
151
|
+
|
|
152
|
+
def get_auth_key(self) -> str:
|
|
153
|
+
if not self._novnc_password:
|
|
154
|
+
raise RuntimeError(
|
|
155
|
+
"Unable to retrieve stream auth key, check if require_auth is enabled"
|
|
156
|
+
)
|
|
157
|
+
return self._novnc_password
|
|
158
|
+
|
|
159
|
+
def start(
|
|
160
|
+
self,
|
|
161
|
+
vnc_port: Optional[int] = None,
|
|
162
|
+
port: Optional[int] = None,
|
|
163
|
+
require_auth: bool = False,
|
|
164
|
+
window_id: Optional[str] = None,
|
|
165
|
+
) -> None:
|
|
166
|
+
# If stream is already running, throw an error
|
|
167
|
+
if self._check_vnc_running():
|
|
168
|
+
raise RuntimeError("Stream is already running")
|
|
169
|
+
|
|
170
|
+
# Update parameters if provided
|
|
171
|
+
self._vnc_port = vnc_port or self._vnc_port
|
|
172
|
+
self._port = port or self._port
|
|
173
|
+
self._novnc_auth_enabled = require_auth or self._novnc_auth_enabled
|
|
174
|
+
self._novnc_password = self._generate_password() if require_auth else None
|
|
175
|
+
|
|
176
|
+
# Update URL with new port
|
|
177
|
+
self._url = f"https://{self.__desktop.get_host(self._port)}/vnc.html"
|
|
178
|
+
|
|
179
|
+
# Set up VNC command
|
|
180
|
+
pwd_flag = "-nopw"
|
|
181
|
+
if self._novnc_auth_enabled:
|
|
182
|
+
self.__desktop.commands.run("mkdir -p ~/.vnc")
|
|
183
|
+
self.__desktop.commands.run(
|
|
184
|
+
f"x11vnc -storepasswd {self._novnc_password} ~/.vnc/passwd"
|
|
185
|
+
)
|
|
186
|
+
pwd_flag = "-usepw"
|
|
187
|
+
|
|
188
|
+
window_id_flag = ""
|
|
189
|
+
if window_id:
|
|
190
|
+
window_id_flag = f"-id {window_id}"
|
|
191
|
+
|
|
192
|
+
vnc_command = (
|
|
193
|
+
f"x11vnc -bg -display {self.__desktop._display} -forever -wait 50 -shared "
|
|
194
|
+
f"-rfbport {self._vnc_port} {pwd_flag} 2>/tmp/x11vnc_stderr.log {window_id_flag}"
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
novnc_command = (
|
|
198
|
+
f"cd /opt/noVNC/utils && ./novnc_proxy --vnc localhost:{self._vnc_port} "
|
|
199
|
+
f"--listen {self._port} --web /opt/noVNC > /tmp/novnc.log 2>&1"
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
self.__desktop.commands.run(vnc_command)
|
|
203
|
+
|
|
204
|
+
self.__novnc_handle = self.__desktop.commands.run(
|
|
205
|
+
novnc_command, background=True, timeout=0
|
|
206
|
+
)
|
|
207
|
+
if not self._wait_for_port(self._port):
|
|
208
|
+
raise TimeoutException("Could not start noVNC server")
|
|
209
|
+
|
|
210
|
+
def stop(self) -> None:
|
|
211
|
+
if self._check_vnc_running():
|
|
212
|
+
self.__desktop.commands.run("pkill x11vnc")
|
|
213
|
+
|
|
214
|
+
if self.__novnc_handle:
|
|
215
|
+
self.__novnc_handle.kill()
|
|
216
|
+
self.__novnc_handle = None
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
class Sandbox(SandboxBase):
|
|
220
|
+
default_template = "desktop"
|
|
221
|
+
__vnc_server: _VNCServer
|
|
222
|
+
_last_xfce4_pid: Optional[str] = None
|
|
223
|
+
_display: str
|
|
224
|
+
|
|
225
|
+
@classmethod
|
|
226
|
+
def create(
|
|
227
|
+
cls,
|
|
228
|
+
template: Optional[str] = None,
|
|
229
|
+
resolution: Optional[Tuple[int, int]] = None,
|
|
230
|
+
dpi: Optional[int] = None,
|
|
231
|
+
display: Optional[str] = None,
|
|
232
|
+
timeout: Optional[int] = None,
|
|
233
|
+
metadata: Optional[Dict[str, str]] = None,
|
|
234
|
+
envs: Optional[Dict[str, str]] = None,
|
|
235
|
+
secure: Optional[bool] = None,
|
|
236
|
+
allow_internet_access: Optional[bool] = None,
|
|
237
|
+
network: Optional[SandboxNetworkOpts] = None,
|
|
238
|
+
iam: Optional[SandboxIamOpts] = None,
|
|
239
|
+
lifecycle: Optional[SandboxLifecycle] = None,
|
|
240
|
+
volume_mounts: Optional[Dict[str, Union[Volume, str]]] = None,
|
|
241
|
+
logger: Optional[logging.Logger] = None,
|
|
242
|
+
**opts: Unpack[ApiParams],
|
|
243
|
+
) -> Self:
|
|
244
|
+
"""
|
|
245
|
+
Create a new sandbox.
|
|
246
|
+
|
|
247
|
+
By default, the sandbox is created from the default `desktop` sandbox template.
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
:param template: Sandbox template name or ID
|
|
251
|
+
:param resolution: Startup the desktop with custom screen resolution. Defaults to (1024, 768)
|
|
252
|
+
:param dpi: Startup the desktop with custom DPI. Defaults to 96
|
|
253
|
+
:param display: Startup the desktop with custom display. Defaults to ":0"
|
|
254
|
+
:param timeout: Timeout for the sandbox in **seconds**, default to 300 seconds. The maximum time a sandbox can be kept alive is 24 hours (86_400 seconds) for Pro users and 1 hour (3_600 seconds) for Hobby users.
|
|
255
|
+
:param metadata: Custom metadata for the sandbox
|
|
256
|
+
:param envs: Custom environment variables for the sandbox
|
|
257
|
+
:param secure: Deprecated — every sandbox secures envd access; accepted for backward compatibility and ignored
|
|
258
|
+
:param allow_internet_access: Allow sandbox to access the internet
|
|
259
|
+
|
|
260
|
+
:return: A Sandbox instance for the new sandbox
|
|
261
|
+
|
|
262
|
+
Use this method instead of using the constructor to create a new sandbox.
|
|
263
|
+
"""
|
|
264
|
+
|
|
265
|
+
# Initialize environment variables with DISPLAY
|
|
266
|
+
display = display or ":0"
|
|
267
|
+
if envs is None:
|
|
268
|
+
envs = {}
|
|
269
|
+
envs["DISPLAY"] = display
|
|
270
|
+
|
|
271
|
+
sbx = super().create(
|
|
272
|
+
template=template,
|
|
273
|
+
timeout=timeout,
|
|
274
|
+
metadata=metadata,
|
|
275
|
+
envs=envs,
|
|
276
|
+
allow_internet_access=allow_internet_access,
|
|
277
|
+
network=network,
|
|
278
|
+
iam=iam,
|
|
279
|
+
lifecycle=lifecycle,
|
|
280
|
+
volume_mounts=volume_mounts,
|
|
281
|
+
logger=logger,
|
|
282
|
+
**opts,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
try:
|
|
286
|
+
sbx._display = display
|
|
287
|
+
width, height = resolution or (1024, 768)
|
|
288
|
+
xvfb_handle = sbx.commands.run(
|
|
289
|
+
f"Xvfb {display} -ac -screen 0 {width}x{height}x24"
|
|
290
|
+
f" -retro -dpi {dpi or 96} -nolisten tcp -nolisten unix",
|
|
291
|
+
background=True,
|
|
292
|
+
timeout=0,
|
|
293
|
+
)
|
|
294
|
+
xvfb_handle.disconnect()
|
|
295
|
+
|
|
296
|
+
if not sbx._wait_and_verify(
|
|
297
|
+
f"xdpyinfo -display {display}", lambda r: r.exit_code == 0
|
|
298
|
+
):
|
|
299
|
+
raise TimeoutException("Could not start Xvfb")
|
|
300
|
+
|
|
301
|
+
sbx.__vnc_server = _VNCServer(sbx)
|
|
302
|
+
sbx._start_xfce4()
|
|
303
|
+
except Exception:
|
|
304
|
+
try:
|
|
305
|
+
sbx.kill()
|
|
306
|
+
except Exception:
|
|
307
|
+
pass
|
|
308
|
+
raise
|
|
309
|
+
|
|
310
|
+
return sbx
|
|
311
|
+
|
|
312
|
+
def _wait_and_verify(
|
|
313
|
+
self,
|
|
314
|
+
cmd: str,
|
|
315
|
+
on_result: Callable[[CommandResult], bool],
|
|
316
|
+
timeout: int = 10,
|
|
317
|
+
interval: float = 0.5,
|
|
318
|
+
) -> bool:
|
|
319
|
+
elapsed = 0
|
|
320
|
+
while elapsed < timeout:
|
|
321
|
+
try:
|
|
322
|
+
if on_result(self.commands.run(cmd)):
|
|
323
|
+
return True
|
|
324
|
+
except CommandExitException:
|
|
325
|
+
pass
|
|
326
|
+
|
|
327
|
+
time.sleep(interval)
|
|
328
|
+
elapsed += interval
|
|
329
|
+
|
|
330
|
+
return False
|
|
331
|
+
|
|
332
|
+
def _start_xfce4(self):
|
|
333
|
+
"""
|
|
334
|
+
Start xfce4 session if logged out or not running.
|
|
335
|
+
"""
|
|
336
|
+
if self._last_xfce4_pid is None or "[xfce4-session] <defunct>" in (
|
|
337
|
+
self.commands.run(
|
|
338
|
+
f"ps aux | grep {self._last_xfce4_pid} | grep -v grep | head -n 1"
|
|
339
|
+
).stdout.strip()
|
|
340
|
+
):
|
|
341
|
+
xfce4_handle = self.commands.run("startxfce4", background=True, timeout=0)
|
|
342
|
+
self._last_xfce4_pid = xfce4_handle.pid
|
|
343
|
+
xfce4_handle.disconnect()
|
|
344
|
+
|
|
345
|
+
if not self._wait_and_verify(
|
|
346
|
+
"pgrep -x xfce4-session >/dev/null && pgrep -x xfwm4 >/dev/null && pgrep -x xfdesktop >/dev/null",
|
|
347
|
+
lambda r: r.exit_code == 0,
|
|
348
|
+
60,
|
|
349
|
+
):
|
|
350
|
+
raise TimeoutException("Could not start XFCE")
|
|
351
|
+
|
|
352
|
+
@property
|
|
353
|
+
def stream(self) -> _VNCServer:
|
|
354
|
+
return self.__vnc_server
|
|
355
|
+
|
|
356
|
+
@overload
|
|
357
|
+
def screenshot(self, format: Literal["stream"]) -> Iterator[bytes]:
|
|
358
|
+
"""
|
|
359
|
+
Take a screenshot and return it as a stream of bytes.
|
|
360
|
+
"""
|
|
361
|
+
|
|
362
|
+
@overload
|
|
363
|
+
def screenshot(
|
|
364
|
+
self,
|
|
365
|
+
format: Literal["bytes"] = "bytes",
|
|
366
|
+
) -> bytearray:
|
|
367
|
+
"""
|
|
368
|
+
Take a screenshot and return it as a bytearray.
|
|
369
|
+
"""
|
|
370
|
+
|
|
371
|
+
def screenshot(
|
|
372
|
+
self,
|
|
373
|
+
format: Literal["bytes", "stream"] = "bytes",
|
|
374
|
+
):
|
|
375
|
+
"""
|
|
376
|
+
Take a screenshot and return it in the specified format.
|
|
377
|
+
|
|
378
|
+
:param format: The format of the screenshot. Can be 'bytes', 'blob', or 'stream'.
|
|
379
|
+
:returns: The screenshot in the specified format.
|
|
380
|
+
"""
|
|
381
|
+
screenshot_path = f"/tmp/screenshot-{uuid4()}.png"
|
|
382
|
+
|
|
383
|
+
self.commands.run(f"scrot --pointer {screenshot_path}")
|
|
384
|
+
|
|
385
|
+
file = self.files.read(screenshot_path, format=format)
|
|
386
|
+
self.files.remove(screenshot_path)
|
|
387
|
+
return file
|
|
388
|
+
|
|
389
|
+
def left_click(self, x: Optional[int] = None, y: Optional[int] = None):
|
|
390
|
+
"""
|
|
391
|
+
Left click on the mouse position.
|
|
392
|
+
"""
|
|
393
|
+
if x and y:
|
|
394
|
+
self.move_mouse(x, y)
|
|
395
|
+
self.commands.run("xdotool click 1")
|
|
396
|
+
|
|
397
|
+
def double_click(self, x: Optional[int] = None, y: Optional[int] = None):
|
|
398
|
+
"""
|
|
399
|
+
Double left click on the mouse position.
|
|
400
|
+
"""
|
|
401
|
+
if x and y:
|
|
402
|
+
self.move_mouse(x, y)
|
|
403
|
+
self.commands.run("xdotool click --repeat 2 1")
|
|
404
|
+
|
|
405
|
+
def right_click(self, x: Optional[int] = None, y: Optional[int] = None):
|
|
406
|
+
if (x is None) != (y is None):
|
|
407
|
+
raise ValueError("Both x and y must be provided together")
|
|
408
|
+
"""
|
|
409
|
+
Right click on the mouse position.
|
|
410
|
+
"""
|
|
411
|
+
if x and y:
|
|
412
|
+
self.move_mouse(x, y)
|
|
413
|
+
self.commands.run("xdotool click 3")
|
|
414
|
+
|
|
415
|
+
def middle_click(self, x: Optional[int] = None, y: Optional[int] = None):
|
|
416
|
+
"""
|
|
417
|
+
Middle click on the mouse position.
|
|
418
|
+
"""
|
|
419
|
+
if x and y:
|
|
420
|
+
self.move_mouse(x, y)
|
|
421
|
+
self.commands.run("xdotool click 2")
|
|
422
|
+
|
|
423
|
+
def scroll(self, direction: Literal["up", "down"] = "down", amount: int = 1):
|
|
424
|
+
"""
|
|
425
|
+
Scroll the mouse wheel by the given amount.
|
|
426
|
+
|
|
427
|
+
:param direction: The direction to scroll. Can be "up" or "down".
|
|
428
|
+
:param amount: The amount to scroll.
|
|
429
|
+
"""
|
|
430
|
+
self.commands.run(
|
|
431
|
+
f"xdotool click --repeat {amount} {'4' if direction == 'up' else '5'}"
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
def move_mouse(self, x: int, y: int):
|
|
435
|
+
"""
|
|
436
|
+
Move the mouse to the given coordinates.
|
|
437
|
+
|
|
438
|
+
:param x: The x coordinate.
|
|
439
|
+
:param y: The y coordinate.
|
|
440
|
+
"""
|
|
441
|
+
self.commands.run(f"xdotool mousemove --sync {x} {y}")
|
|
442
|
+
|
|
443
|
+
def mouse_press(self, button: Literal["left", "right", "middle"] = "left"):
|
|
444
|
+
"""
|
|
445
|
+
Press the mouse button.
|
|
446
|
+
"""
|
|
447
|
+
self.commands.run(f"xdotool mousedown {MOUSE_BUTTONS[button]}")
|
|
448
|
+
|
|
449
|
+
def mouse_release(self, button: Literal["left", "right", "middle"] = "left"):
|
|
450
|
+
"""
|
|
451
|
+
Release the mouse button.
|
|
452
|
+
"""
|
|
453
|
+
self.commands.run(f"xdotool mouseup {MOUSE_BUTTONS[button]}")
|
|
454
|
+
|
|
455
|
+
def get_cursor_position(self) -> tuple[int, int]:
|
|
456
|
+
"""
|
|
457
|
+
Get the current cursor position.
|
|
458
|
+
|
|
459
|
+
:return: A tuple with the x and y coordinates
|
|
460
|
+
:raises RuntimeError: If the cursor position cannot be determined
|
|
461
|
+
"""
|
|
462
|
+
result = self.commands.run("xdotool getmouselocation")
|
|
463
|
+
|
|
464
|
+
groups = re_search(r"x:(\d+)\s+y:(\d+)", result.stdout)
|
|
465
|
+
if not groups:
|
|
466
|
+
raise RuntimeError(
|
|
467
|
+
f"Failed to parse cursor position from output: {result.stdout}"
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
x, y = groups.group(1), groups.group(2)
|
|
471
|
+
if not x or not y:
|
|
472
|
+
raise RuntimeError(f"Invalid cursor position values: x={x}, y={y}")
|
|
473
|
+
|
|
474
|
+
return int(x), int(y)
|
|
475
|
+
|
|
476
|
+
def get_screen_size(self) -> tuple[int, int]:
|
|
477
|
+
"""
|
|
478
|
+
Get the current screen size.
|
|
479
|
+
|
|
480
|
+
:return: A tuple with the width and height
|
|
481
|
+
:raises RuntimeError: If the screen size cannot be determined
|
|
482
|
+
"""
|
|
483
|
+
result = self.commands.run("xrandr")
|
|
484
|
+
|
|
485
|
+
_match = re_search(r"(\d+x\d+)", result.stdout)
|
|
486
|
+
if not _match:
|
|
487
|
+
raise RuntimeError(
|
|
488
|
+
f"Failed to parse screen size from output: {result.stdout}"
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
try:
|
|
492
|
+
return tuple(map(int, _match.group(1).split("x"))) # type: ignore
|
|
493
|
+
except (ValueError, IndexError) as e:
|
|
494
|
+
raise RuntimeError(f"Invalid screen size format: {_match.group(1)}") from e
|
|
495
|
+
|
|
496
|
+
def write(self, text: str, *, chunk_size: int = 25, delay_in_ms: int = 75) -> None:
|
|
497
|
+
"""
|
|
498
|
+
Write the given text at the current cursor position.
|
|
499
|
+
|
|
500
|
+
:param text: The text to write.
|
|
501
|
+
:param chunk_size: The size of each chunk of text to write.
|
|
502
|
+
:param delay_in_ms: The delay between each chunk of text.
|
|
503
|
+
"""
|
|
504
|
+
|
|
505
|
+
def break_into_chunks(text: str, n: int):
|
|
506
|
+
for i in range(0, len(text), n):
|
|
507
|
+
yield text[i : i + n]
|
|
508
|
+
|
|
509
|
+
for chunk in break_into_chunks(text, chunk_size):
|
|
510
|
+
self.commands.run(
|
|
511
|
+
f"xdotool type --delay {delay_in_ms} -- {quote_string(chunk)}"
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
def press(self, key: Union[str, list[str]]):
|
|
515
|
+
"""
|
|
516
|
+
Press a key.
|
|
517
|
+
|
|
518
|
+
:param key: The key to press (e.g. "enter", "space", "backspace", etc.).
|
|
519
|
+
"""
|
|
520
|
+
if isinstance(key, list):
|
|
521
|
+
key = "+".join(map_key(k) for k in key)
|
|
522
|
+
else:
|
|
523
|
+
key = map_key(key)
|
|
524
|
+
|
|
525
|
+
self.commands.run(f"xdotool key {key}")
|
|
526
|
+
|
|
527
|
+
def drag(self, fr: tuple[int, int], to: tuple[int, int]):
|
|
528
|
+
"""
|
|
529
|
+
Drag the mouse from the given position to the given position.
|
|
530
|
+
|
|
531
|
+
:param from: The starting position.
|
|
532
|
+
:param to: The ending position.
|
|
533
|
+
"""
|
|
534
|
+
self.move_mouse(fr[0], fr[1])
|
|
535
|
+
self.mouse_press()
|
|
536
|
+
self.move_mouse(to[0], to[1])
|
|
537
|
+
self.mouse_release()
|
|
538
|
+
|
|
539
|
+
def wait(self, ms: int):
|
|
540
|
+
"""
|
|
541
|
+
Wait for the given amount of time.
|
|
542
|
+
|
|
543
|
+
:param ms: The amount of time to wait in milliseconds.
|
|
544
|
+
"""
|
|
545
|
+
self.commands.run(f"sleep {ms / 1000}")
|
|
546
|
+
|
|
547
|
+
def open(self, file_or_url: str):
|
|
548
|
+
"""
|
|
549
|
+
Open a file or a URL in the default application.
|
|
550
|
+
|
|
551
|
+
:param file_or_url: The file or URL to open.
|
|
552
|
+
"""
|
|
553
|
+
handle = self.commands.run(f"xdg-open {file_or_url}", background=True)
|
|
554
|
+
handle.disconnect()
|
|
555
|
+
|
|
556
|
+
def get_current_window_id(self) -> str:
|
|
557
|
+
"""
|
|
558
|
+
Get the current window ID.
|
|
559
|
+
"""
|
|
560
|
+
return self.commands.run("xdotool getwindowfocus").stdout.strip()
|
|
561
|
+
|
|
562
|
+
def get_application_windows(self, application: str) -> list[str]:
|
|
563
|
+
"""
|
|
564
|
+
Get the window IDs of all windows for the given application.
|
|
565
|
+
"""
|
|
566
|
+
return (
|
|
567
|
+
self.commands.run(f"xdotool search --onlyvisible --class {application}")
|
|
568
|
+
.stdout.strip()
|
|
569
|
+
.split("\n")
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
def get_window_title(self, window_id: str) -> str:
|
|
573
|
+
"""
|
|
574
|
+
Get the title of the window with the given ID.
|
|
575
|
+
"""
|
|
576
|
+
return self.commands.run(f"xdotool getwindowname {window_id}").stdout.strip()
|
|
577
|
+
|
|
578
|
+
def launch(self, application: str, uri: Optional[str] = None):
|
|
579
|
+
"""
|
|
580
|
+
Launch an application.
|
|
581
|
+
"""
|
|
582
|
+
handle = self.commands.run(
|
|
583
|
+
f"gtk-launch {application} {uri or ''}", background=True, timeout=0
|
|
584
|
+
)
|
|
585
|
+
handle.disconnect()
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "boxfab-desktop"
|
|
3
|
+
version = "0.1.0a1"
|
|
4
|
+
description = "BoxFab Desktop: a virtual Linux desktop in a BoxFab sandbox, for computer-use agents"
|
|
5
|
+
license = "MIT"
|
|
6
|
+
license-files = ["LICENSE"]
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
requires-python = ">=3.11"
|
|
9
|
+
classifiers = [
|
|
10
|
+
"Development Status :: 3 - Alpha",
|
|
11
|
+
"Intended Audience :: Developers",
|
|
12
|
+
"Programming Language :: Python :: 3",
|
|
13
|
+
"Programming Language :: Python :: 3.11",
|
|
14
|
+
"Programming Language :: Python :: 3.12",
|
|
15
|
+
"Programming Language :: Python :: 3.13",
|
|
16
|
+
]
|
|
17
|
+
dependencies = [
|
|
18
|
+
"boxfab>=0.1.0a3,<0.2",
|
|
19
|
+
"requests>=2.32.3,<3",
|
|
20
|
+
"urllib3>=2.8.0,<3",
|
|
21
|
+
"pillow>=13.0.0,<14",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[[project.authors]]
|
|
25
|
+
name = "ContextFab, Inc."
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Repository = "https://github.com/boxfab/python-sdk"
|
|
29
|
+
|
|
30
|
+
[dependency-groups]
|
|
31
|
+
dev = [
|
|
32
|
+
"pytest>=9.0.3,<10",
|
|
33
|
+
"python-dotenv>=1.0.0,<2",
|
|
34
|
+
"pytest-dotenv>=0.5.2,<0.6",
|
|
35
|
+
"pytest-xdist>=3.6.1,<4",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[tool.uv.sources.boxfab]
|
|
39
|
+
path = ".."
|
|
40
|
+
editable = true
|
|
41
|
+
|
|
42
|
+
[tool.uv.build-backend]
|
|
43
|
+
module-name = ["boxfab_desktop"]
|
|
44
|
+
module-root = ""
|
|
45
|
+
source-exclude = ["tests"]
|
|
46
|
+
|
|
47
|
+
[build-system]
|
|
48
|
+
requires = ["uv_build>=0.13.4,<0.14.0"]
|
|
49
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "boxfab-desktop"
|
|
3
|
+
version = "0.1.0a1"
|
|
4
|
+
description = "BoxFab Desktop: a virtual Linux desktop in a BoxFab sandbox, for computer-use agents"
|
|
5
|
+
authors = [{ name = "ContextFab, Inc." }]
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 3 - Alpha",
|
|
12
|
+
"Intended Audience :: Developers",
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
"Programming Language :: Python :: 3.11",
|
|
15
|
+
"Programming Language :: Python :: 3.12",
|
|
16
|
+
"Programming Language :: Python :: 3.13",
|
|
17
|
+
]
|
|
18
|
+
# The core SDK in this repository is generated from the same upstream commit. The other ranges
|
|
19
|
+
# are upstream's (e2b-desktop 2.7.2).
|
|
20
|
+
dependencies = [
|
|
21
|
+
"boxfab>=0.1.0a3,<0.2",
|
|
22
|
+
"requests>=2.32.3,<3",
|
|
23
|
+
"urllib3>=2.8.0,<3",
|
|
24
|
+
"pillow>=13.0.0,<14",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Repository = "https://github.com/boxfab/python-sdk"
|
|
29
|
+
|
|
30
|
+
[dependency-groups]
|
|
31
|
+
dev = [
|
|
32
|
+
"pytest>=9.0.3,<10",
|
|
33
|
+
"python-dotenv>=1.0.0,<2",
|
|
34
|
+
"pytest-dotenv>=0.5.2,<0.6",
|
|
35
|
+
"pytest-xdist>=3.6.1,<4",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
# Development uses the core SDK from this repository; the published package depends on the
|
|
39
|
+
# released `boxfab` (range above).
|
|
40
|
+
[tool.uv.sources]
|
|
41
|
+
boxfab = { path = "..", editable = true }
|
|
42
|
+
|
|
43
|
+
[build-system]
|
|
44
|
+
requires = ["uv_build>=0.13.4,<0.14.0"]
|
|
45
|
+
build-backend = "uv_build"
|
|
46
|
+
|
|
47
|
+
[tool.uv.build-backend]
|
|
48
|
+
module-name = ["boxfab_desktop"]
|
|
49
|
+
module-root = ""
|
|
50
|
+
source-exclude = ["tests"]
|