lizard-sdk 0.1.1__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.
- lizard_sdk-0.1.1/.gitignore +11 -0
- lizard_sdk-0.1.1/PKG-INFO +208 -0
- lizard_sdk-0.1.1/README.md +195 -0
- lizard_sdk-0.1.1/lizard/__init__.py +18 -0
- lizard_sdk-0.1.1/lizard/code_interpreter/__init__.py +23 -0
- lizard_sdk-0.1.1/lizard/code_interpreter/sandbox.py +169 -0
- lizard_sdk-0.1.1/lizard/code_interpreter/types.py +67 -0
- lizard_sdk-0.1.1/lizard/config.py +28 -0
- lizard_sdk-0.1.1/lizard/errors.py +20 -0
- lizard_sdk-0.1.1/lizard/sandbox/__init__.py +5 -0
- lizard_sdk-0.1.1/lizard/sandbox/fs.py +148 -0
- lizard_sdk-0.1.1/lizard/sandbox/process.py +93 -0
- lizard_sdk-0.1.1/lizard/sandbox/sandbox.py +287 -0
- lizard_sdk-0.1.1/pyproject.toml +20 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: lizard-sdk
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Lizard SDK — cloud sandboxes for AI agents
|
|
5
|
+
License: Apache-2.0
|
|
6
|
+
Requires-Python: >=3.8
|
|
7
|
+
Requires-Dist: httpx>=0.24.0
|
|
8
|
+
Provides-Extra: dev
|
|
9
|
+
Requires-Dist: mypy; extra == 'dev'
|
|
10
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
11
|
+
Requires-Dist: pytest-asyncio; extra == 'dev'
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# Lizard SDK
|
|
15
|
+
|
|
16
|
+
Firecracker microVM sandboxes for AI agents — boot a full Linux environment in milliseconds, run code, write files, and expose ports, all from your agent or CI pipeline.
|
|
17
|
+
|
|
18
|
+
Each sandbox is an isolated microVM with its own filesystem, network, and process namespace. Sandboxes can be snapshotted and resumed instantly, so long-running agent sessions survive restarts without re-running setup.
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
# JavaScript / TypeScript
|
|
24
|
+
npm install @lizard-build/sdk
|
|
25
|
+
|
|
26
|
+
# Python
|
|
27
|
+
pip install lizard-sdk
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Quickstart
|
|
31
|
+
|
|
32
|
+
### JavaScript / TypeScript
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
import { Sandbox } from '@lizard-build/sdk'
|
|
36
|
+
|
|
37
|
+
// Boot a Node.js microVM from the 'node22' template
|
|
38
|
+
const sandbox = await Sandbox.create('node22')
|
|
39
|
+
|
|
40
|
+
// Write a file directly into the microVM filesystem
|
|
41
|
+
await sandbox.fs.write('/app/server.js', `
|
|
42
|
+
const http = require('http')
|
|
43
|
+
http.createServer((_, res) => res.end('hello from Lizard')).listen(3000)
|
|
44
|
+
`)
|
|
45
|
+
|
|
46
|
+
// Execute a process inside the microVM
|
|
47
|
+
await sandbox.process.exec('node /app/server.js &')
|
|
48
|
+
|
|
49
|
+
// Get a public HTTPS URL for port 3000 inside the sandbox
|
|
50
|
+
const url = sandbox.getHost(3000)
|
|
51
|
+
console.log(`Live at https://${url}`)
|
|
52
|
+
|
|
53
|
+
// Tear down the microVM when done
|
|
54
|
+
await sandbox.kill()
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Python
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from lizard import Sandbox
|
|
61
|
+
|
|
62
|
+
# Boot a Python microVM from the 'python312' template
|
|
63
|
+
sandbox = Sandbox.create("python312")
|
|
64
|
+
|
|
65
|
+
# Write a script into the microVM filesystem
|
|
66
|
+
sandbox.fs.write("/app/main.py", """
|
|
67
|
+
import http.server, socketserver
|
|
68
|
+
|
|
69
|
+
class Handler(http.server.SimpleHTTPRequestHandler):
|
|
70
|
+
def do_GET(self):
|
|
71
|
+
self.send_response(200)
|
|
72
|
+
self.end_headers()
|
|
73
|
+
self.wfile.write(b"hello from Lizard")
|
|
74
|
+
|
|
75
|
+
with socketserver.TCPServer(("", 3000), Handler) as httpd:
|
|
76
|
+
httpd.serve_forever()
|
|
77
|
+
""")
|
|
78
|
+
|
|
79
|
+
# Execute a process inside the microVM
|
|
80
|
+
sandbox.process.exec_("python /app/main.py &")
|
|
81
|
+
|
|
82
|
+
print(f"Live at https://{sandbox.get_host(3000)}")
|
|
83
|
+
|
|
84
|
+
sandbox.kill()
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Pause and Resume
|
|
88
|
+
|
|
89
|
+
Sandboxes can be snapshotted mid-execution and resumed exactly where they left off — including installed packages, in-memory state, and running processes. This makes Lizard sandboxes well-suited for long-running AI agent workflows where you want to checkpoint and continue across separate invocations.
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
// Boot and set up the environment once
|
|
93
|
+
const sandbox = await Sandbox.create('python312')
|
|
94
|
+
await sandbox.process.exec('pip install numpy pandas scikit-learn')
|
|
95
|
+
const id = sandbox.sandboxId
|
|
96
|
+
await sandbox.pause()
|
|
97
|
+
|
|
98
|
+
// Later — resume instantly from the snapshot (no reinstall needed)
|
|
99
|
+
const resumed = await Sandbox.connect(id)
|
|
100
|
+
const result = await resumed.process.exec('python -c "import sklearn; print(sklearn.__version__)"')
|
|
101
|
+
console.log(result.stdout)
|
|
102
|
+
await resumed.kill()
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
from lizard import Sandbox
|
|
107
|
+
|
|
108
|
+
sandbox = Sandbox.create("python312")
|
|
109
|
+
sandbox.process.exec_("pip install numpy pandas scikit-learn")
|
|
110
|
+
sandbox_id = sandbox.sandbox_id
|
|
111
|
+
sandbox.pause()
|
|
112
|
+
|
|
113
|
+
# Resume later — environment is exactly as left
|
|
114
|
+
resumed = Sandbox.connect(sandbox_id)
|
|
115
|
+
result = resumed.process.exec_("python -c 'import sklearn; print(sklearn.__version__)'")
|
|
116
|
+
print(result.stdout)
|
|
117
|
+
resumed.kill()
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## API
|
|
121
|
+
|
|
122
|
+
### `Sandbox.create(template?, opts?)`
|
|
123
|
+
|
|
124
|
+
Boot a new Lizard microVM. Built-in templates: `base`, `node22`, `python312`. Custom templates can be pushed via `lizard push`.
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
const sandbox = await Sandbox.create('node22')
|
|
128
|
+
const sandbox = await Sandbox.create('python312', { timeoutMs: 10 * 60 * 1000 })
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### `Sandbox.connect(sandboxId, opts?)`
|
|
132
|
+
|
|
133
|
+
Connect to an existing sandbox by ID. If the sandbox is paused, it is automatically resumed from its last snapshot.
|
|
134
|
+
|
|
135
|
+
### `Sandbox.list(opts?)`
|
|
136
|
+
|
|
137
|
+
List all running sandboxes for the authenticated account.
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
### `sandbox.fs`
|
|
142
|
+
|
|
143
|
+
Read and write files inside the microVM filesystem.
|
|
144
|
+
|
|
145
|
+
| Method | Description |
|
|
146
|
+
|---|---|
|
|
147
|
+
| `fs.write(path, data)` | Write a file (string or bytes) |
|
|
148
|
+
| `fs.read(path)` | Read a file as a string |
|
|
149
|
+
| `fs.list(path)` | List directory contents |
|
|
150
|
+
| `fs.remove(path)` | Delete a file or directory |
|
|
151
|
+
| `fs.makeDir(path)` | Create a directory and parents |
|
|
152
|
+
|
|
153
|
+
### `sandbox.process`
|
|
154
|
+
|
|
155
|
+
Execute commands inside the microVM.
|
|
156
|
+
|
|
157
|
+
| Method | Description |
|
|
158
|
+
|---|---|
|
|
159
|
+
| `process.exec(cmd, opts?)` | Run a command and wait for it to finish |
|
|
160
|
+
|
|
161
|
+
`exec` returns `{ stdout, stderr, exitCode }` (JS) or `ProcessResult` (Python). In Python the method is named `exec_` because `exec` is a reserved keyword.
|
|
162
|
+
|
|
163
|
+
### `sandbox.getHost(port)`
|
|
164
|
+
|
|
165
|
+
Returns a public HTTPS URL for a port listening inside the microVM — no tunneling required.
|
|
166
|
+
|
|
167
|
+
```ts
|
|
168
|
+
await sandbox.process.exec('npx -y serve -p 3000 &')
|
|
169
|
+
const url = sandbox.getHost(3000)
|
|
170
|
+
// https://{sandboxId}-3000.sandbox.lizard.run
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
### `sandbox.pause()` / `sandbox.resume()`
|
|
174
|
+
|
|
175
|
+
Snapshot and restore the microVM state. Useful for checkpointing long agent sessions.
|
|
176
|
+
|
|
177
|
+
### `sandbox.kill()`
|
|
178
|
+
|
|
179
|
+
Terminate the sandbox and release all resources.
|
|
180
|
+
|
|
181
|
+
### `sandbox.setTimeout(ms)`
|
|
182
|
+
|
|
183
|
+
Extend or reduce the sandbox timeout.
|
|
184
|
+
|
|
185
|
+
---
|
|
186
|
+
|
|
187
|
+
## Environment Variables
|
|
188
|
+
|
|
189
|
+
| Variable | Description |
|
|
190
|
+
|---|---|
|
|
191
|
+
| `LIZARD_API_KEY` | API key (required — get one at [lizard.run](https://lizard.run)) |
|
|
192
|
+
| `LIZARD_API_URL` | Override the API base URL (default: `https://api.lizard.run`) |
|
|
193
|
+
|
|
194
|
+
The `X-API-Key` header is used for all authenticated requests.
|
|
195
|
+
|
|
196
|
+
## Deploy What You Build
|
|
197
|
+
|
|
198
|
+
Once your agent has produced a working app inside a sandbox, deploy it as a persistent Lizard service — no Dockerfile needed:
|
|
199
|
+
|
|
200
|
+
```bash
|
|
201
|
+
lizard up
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Your sandbox template becomes the base, your code ships as a layer on top, and Lizard manages the Firecracker microVM fleet from there.
|
|
205
|
+
|
|
206
|
+
## License
|
|
207
|
+
|
|
208
|
+
Apache-2.0
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# Lizard SDK
|
|
2
|
+
|
|
3
|
+
Firecracker microVM sandboxes for AI agents — boot a full Linux environment in milliseconds, run code, write files, and expose ports, all from your agent or CI pipeline.
|
|
4
|
+
|
|
5
|
+
Each sandbox is an isolated microVM with its own filesystem, network, and process namespace. Sandboxes can be snapshotted and resumed instantly, so long-running agent sessions survive restarts without re-running setup.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
# JavaScript / TypeScript
|
|
11
|
+
npm install @lizard-build/sdk
|
|
12
|
+
|
|
13
|
+
# Python
|
|
14
|
+
pip install lizard-sdk
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Quickstart
|
|
18
|
+
|
|
19
|
+
### JavaScript / TypeScript
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import { Sandbox } from '@lizard-build/sdk'
|
|
23
|
+
|
|
24
|
+
// Boot a Node.js microVM from the 'node22' template
|
|
25
|
+
const sandbox = await Sandbox.create('node22')
|
|
26
|
+
|
|
27
|
+
// Write a file directly into the microVM filesystem
|
|
28
|
+
await sandbox.fs.write('/app/server.js', `
|
|
29
|
+
const http = require('http')
|
|
30
|
+
http.createServer((_, res) => res.end('hello from Lizard')).listen(3000)
|
|
31
|
+
`)
|
|
32
|
+
|
|
33
|
+
// Execute a process inside the microVM
|
|
34
|
+
await sandbox.process.exec('node /app/server.js &')
|
|
35
|
+
|
|
36
|
+
// Get a public HTTPS URL for port 3000 inside the sandbox
|
|
37
|
+
const url = sandbox.getHost(3000)
|
|
38
|
+
console.log(`Live at https://${url}`)
|
|
39
|
+
|
|
40
|
+
// Tear down the microVM when done
|
|
41
|
+
await sandbox.kill()
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Python
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from lizard import Sandbox
|
|
48
|
+
|
|
49
|
+
# Boot a Python microVM from the 'python312' template
|
|
50
|
+
sandbox = Sandbox.create("python312")
|
|
51
|
+
|
|
52
|
+
# Write a script into the microVM filesystem
|
|
53
|
+
sandbox.fs.write("/app/main.py", """
|
|
54
|
+
import http.server, socketserver
|
|
55
|
+
|
|
56
|
+
class Handler(http.server.SimpleHTTPRequestHandler):
|
|
57
|
+
def do_GET(self):
|
|
58
|
+
self.send_response(200)
|
|
59
|
+
self.end_headers()
|
|
60
|
+
self.wfile.write(b"hello from Lizard")
|
|
61
|
+
|
|
62
|
+
with socketserver.TCPServer(("", 3000), Handler) as httpd:
|
|
63
|
+
httpd.serve_forever()
|
|
64
|
+
""")
|
|
65
|
+
|
|
66
|
+
# Execute a process inside the microVM
|
|
67
|
+
sandbox.process.exec_("python /app/main.py &")
|
|
68
|
+
|
|
69
|
+
print(f"Live at https://{sandbox.get_host(3000)}")
|
|
70
|
+
|
|
71
|
+
sandbox.kill()
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Pause and Resume
|
|
75
|
+
|
|
76
|
+
Sandboxes can be snapshotted mid-execution and resumed exactly where they left off — including installed packages, in-memory state, and running processes. This makes Lizard sandboxes well-suited for long-running AI agent workflows where you want to checkpoint and continue across separate invocations.
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
// Boot and set up the environment once
|
|
80
|
+
const sandbox = await Sandbox.create('python312')
|
|
81
|
+
await sandbox.process.exec('pip install numpy pandas scikit-learn')
|
|
82
|
+
const id = sandbox.sandboxId
|
|
83
|
+
await sandbox.pause()
|
|
84
|
+
|
|
85
|
+
// Later — resume instantly from the snapshot (no reinstall needed)
|
|
86
|
+
const resumed = await Sandbox.connect(id)
|
|
87
|
+
const result = await resumed.process.exec('python -c "import sklearn; print(sklearn.__version__)"')
|
|
88
|
+
console.log(result.stdout)
|
|
89
|
+
await resumed.kill()
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
from lizard import Sandbox
|
|
94
|
+
|
|
95
|
+
sandbox = Sandbox.create("python312")
|
|
96
|
+
sandbox.process.exec_("pip install numpy pandas scikit-learn")
|
|
97
|
+
sandbox_id = sandbox.sandbox_id
|
|
98
|
+
sandbox.pause()
|
|
99
|
+
|
|
100
|
+
# Resume later — environment is exactly as left
|
|
101
|
+
resumed = Sandbox.connect(sandbox_id)
|
|
102
|
+
result = resumed.process.exec_("python -c 'import sklearn; print(sklearn.__version__)'")
|
|
103
|
+
print(result.stdout)
|
|
104
|
+
resumed.kill()
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## API
|
|
108
|
+
|
|
109
|
+
### `Sandbox.create(template?, opts?)`
|
|
110
|
+
|
|
111
|
+
Boot a new Lizard microVM. Built-in templates: `base`, `node22`, `python312`. Custom templates can be pushed via `lizard push`.
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
const sandbox = await Sandbox.create('node22')
|
|
115
|
+
const sandbox = await Sandbox.create('python312', { timeoutMs: 10 * 60 * 1000 })
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### `Sandbox.connect(sandboxId, opts?)`
|
|
119
|
+
|
|
120
|
+
Connect to an existing sandbox by ID. If the sandbox is paused, it is automatically resumed from its last snapshot.
|
|
121
|
+
|
|
122
|
+
### `Sandbox.list(opts?)`
|
|
123
|
+
|
|
124
|
+
List all running sandboxes for the authenticated account.
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
### `sandbox.fs`
|
|
129
|
+
|
|
130
|
+
Read and write files inside the microVM filesystem.
|
|
131
|
+
|
|
132
|
+
| Method | Description |
|
|
133
|
+
|---|---|
|
|
134
|
+
| `fs.write(path, data)` | Write a file (string or bytes) |
|
|
135
|
+
| `fs.read(path)` | Read a file as a string |
|
|
136
|
+
| `fs.list(path)` | List directory contents |
|
|
137
|
+
| `fs.remove(path)` | Delete a file or directory |
|
|
138
|
+
| `fs.makeDir(path)` | Create a directory and parents |
|
|
139
|
+
|
|
140
|
+
### `sandbox.process`
|
|
141
|
+
|
|
142
|
+
Execute commands inside the microVM.
|
|
143
|
+
|
|
144
|
+
| Method | Description |
|
|
145
|
+
|---|---|
|
|
146
|
+
| `process.exec(cmd, opts?)` | Run a command and wait for it to finish |
|
|
147
|
+
|
|
148
|
+
`exec` returns `{ stdout, stderr, exitCode }` (JS) or `ProcessResult` (Python). In Python the method is named `exec_` because `exec` is a reserved keyword.
|
|
149
|
+
|
|
150
|
+
### `sandbox.getHost(port)`
|
|
151
|
+
|
|
152
|
+
Returns a public HTTPS URL for a port listening inside the microVM — no tunneling required.
|
|
153
|
+
|
|
154
|
+
```ts
|
|
155
|
+
await sandbox.process.exec('npx -y serve -p 3000 &')
|
|
156
|
+
const url = sandbox.getHost(3000)
|
|
157
|
+
// https://{sandboxId}-3000.sandbox.lizard.run
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### `sandbox.pause()` / `sandbox.resume()`
|
|
161
|
+
|
|
162
|
+
Snapshot and restore the microVM state. Useful for checkpointing long agent sessions.
|
|
163
|
+
|
|
164
|
+
### `sandbox.kill()`
|
|
165
|
+
|
|
166
|
+
Terminate the sandbox and release all resources.
|
|
167
|
+
|
|
168
|
+
### `sandbox.setTimeout(ms)`
|
|
169
|
+
|
|
170
|
+
Extend or reduce the sandbox timeout.
|
|
171
|
+
|
|
172
|
+
---
|
|
173
|
+
|
|
174
|
+
## Environment Variables
|
|
175
|
+
|
|
176
|
+
| Variable | Description |
|
|
177
|
+
|---|---|
|
|
178
|
+
| `LIZARD_API_KEY` | API key (required — get one at [lizard.run](https://lizard.run)) |
|
|
179
|
+
| `LIZARD_API_URL` | Override the API base URL (default: `https://api.lizard.run`) |
|
|
180
|
+
|
|
181
|
+
The `X-API-Key` header is used for all authenticated requests.
|
|
182
|
+
|
|
183
|
+
## Deploy What You Build
|
|
184
|
+
|
|
185
|
+
Once your agent has produced a working app inside a sandbox, deploy it as a persistent Lizard service — no Dockerfile needed:
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
lizard up
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Your sandbox template becomes the base, your code ships as a layer on top, and Lizard manages the Firecracker microVM fleet from there.
|
|
192
|
+
|
|
193
|
+
## License
|
|
194
|
+
|
|
195
|
+
Apache-2.0
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from .sandbox import Sandbox, SandboxInfo, ProcessResult, FileInfo
|
|
2
|
+
from .errors import LizardError, AuthenticationError, NotFoundError, TimeoutError
|
|
3
|
+
from .code_interpreter import CodeSandbox, Execution, ExecutionError, CodeContext
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"Sandbox",
|
|
7
|
+
"SandboxInfo",
|
|
8
|
+
"ProcessResult",
|
|
9
|
+
"FileInfo",
|
|
10
|
+
"LizardError",
|
|
11
|
+
"AuthenticationError",
|
|
12
|
+
"NotFoundError",
|
|
13
|
+
"TimeoutError",
|
|
14
|
+
"CodeSandbox",
|
|
15
|
+
"Execution",
|
|
16
|
+
"ExecutionError",
|
|
17
|
+
"CodeContext",
|
|
18
|
+
]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from .sandbox import CodeSandbox
|
|
2
|
+
from .types import (
|
|
3
|
+
Execution,
|
|
4
|
+
ExecutionError,
|
|
5
|
+
CodeContext,
|
|
6
|
+
ResultItem,
|
|
7
|
+
StdoutItem,
|
|
8
|
+
StderrItem,
|
|
9
|
+
ErrorItem,
|
|
10
|
+
OutputItem,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"CodeSandbox",
|
|
15
|
+
"Execution",
|
|
16
|
+
"ExecutionError",
|
|
17
|
+
"CodeContext",
|
|
18
|
+
"ResultItem",
|
|
19
|
+
"StdoutItem",
|
|
20
|
+
"StderrItem",
|
|
21
|
+
"ErrorItem",
|
|
22
|
+
"OutputItem",
|
|
23
|
+
]
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Callable, Iterator, Optional
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from ..sandbox.sandbox import Sandbox
|
|
9
|
+
from .types import (
|
|
10
|
+
CodeContext,
|
|
11
|
+
Execution,
|
|
12
|
+
ExecutionError,
|
|
13
|
+
OutputItem,
|
|
14
|
+
ResultItem,
|
|
15
|
+
StdoutItem,
|
|
16
|
+
StderrItem,
|
|
17
|
+
ErrorItem,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
_CODE_INTERPRETER_PORT = 8080
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CodeSandbox(Sandbox):
|
|
24
|
+
"""
|
|
25
|
+
A Lizard sandbox with built-in stateful code execution.
|
|
26
|
+
|
|
27
|
+
Extends the base Sandbox with ``run_code()`` — executes code in a
|
|
28
|
+
persistent kernel so variables and imports survive between calls.
|
|
29
|
+
|
|
30
|
+
Supports Python, JavaScript (Node.js), and Bash out of the box.
|
|
31
|
+
|
|
32
|
+
Example::
|
|
33
|
+
|
|
34
|
+
with CodeSandbox.create() as sandbox:
|
|
35
|
+
sandbox.run_code("x = 42")
|
|
36
|
+
result = sandbox.run_code("print(x * 2)")
|
|
37
|
+
print(result.stdout) # "84\\n"
|
|
38
|
+
|
|
39
|
+
Example — JavaScript::
|
|
40
|
+
|
|
41
|
+
result = sandbox.run_code("1 + 1", language="javascript")
|
|
42
|
+
print(result.results[0].data) # "2"
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
_DEFAULT_TEMPLATE = "code-interpreter-v1"
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def _server_url(self) -> str:
|
|
49
|
+
return f"https://{self.get_host(_CODE_INTERPRETER_PORT)}"
|
|
50
|
+
|
|
51
|
+
def run_code(
|
|
52
|
+
self,
|
|
53
|
+
code: str,
|
|
54
|
+
*,
|
|
55
|
+
language: str | None = None,
|
|
56
|
+
context: CodeContext | None = None,
|
|
57
|
+
envs: dict[str, str] | None = None,
|
|
58
|
+
timeout_ms: int = 60_000,
|
|
59
|
+
on_stdout: Callable[[str], None] | None = None,
|
|
60
|
+
on_stderr: Callable[[str], None] | None = None,
|
|
61
|
+
on_result: Callable[[ResultItem], None] | None = None,
|
|
62
|
+
on_error: Callable[[ExecutionError], None] | None = None,
|
|
63
|
+
) -> Execution:
|
|
64
|
+
"""
|
|
65
|
+
Execute code in a persistent kernel.
|
|
66
|
+
|
|
67
|
+
Variables, imports, and function definitions from previous calls are
|
|
68
|
+
available in subsequent calls within the same context.
|
|
69
|
+
|
|
70
|
+
:param code: Source code to run.
|
|
71
|
+
:param language: ``'python'``, ``'javascript'``, or ``'bash'``.
|
|
72
|
+
Defaults to ``'python'``.
|
|
73
|
+
:param context: Run in a specific context instead of the default one.
|
|
74
|
+
:param envs: Extra environment variables available to the code.
|
|
75
|
+
:param timeout_ms: Timeout in milliseconds. Default 60 000.
|
|
76
|
+
:param on_stdout: Callback invoked for each stdout chunk.
|
|
77
|
+
:param on_stderr: Callback invoked for each stderr chunk.
|
|
78
|
+
:param on_result: Callback invoked for each rich result item.
|
|
79
|
+
:param on_error: Callback invoked if the code throws an exception.
|
|
80
|
+
:returns: :class:`Execution` with stdout, stderr, results, and error.
|
|
81
|
+
"""
|
|
82
|
+
if context and language:
|
|
83
|
+
raise ValueError("Provide context or language, not both")
|
|
84
|
+
|
|
85
|
+
body: dict = {"code": code, "env_vars": envs or {}}
|
|
86
|
+
if context:
|
|
87
|
+
body["context_id"] = context.id
|
|
88
|
+
elif language:
|
|
89
|
+
body["language"] = language
|
|
90
|
+
|
|
91
|
+
execution = Execution()
|
|
92
|
+
|
|
93
|
+
with httpx.stream(
|
|
94
|
+
"POST",
|
|
95
|
+
f"{self._server_url}/execute",
|
|
96
|
+
json=body,
|
|
97
|
+
timeout=timeout_ms / 1000,
|
|
98
|
+
) as resp:
|
|
99
|
+
resp.raise_for_status()
|
|
100
|
+
|
|
101
|
+
for raw in resp.iter_lines():
|
|
102
|
+
if not raw.strip():
|
|
103
|
+
continue
|
|
104
|
+
try:
|
|
105
|
+
item = json.loads(raw)
|
|
106
|
+
except json.JSONDecodeError:
|
|
107
|
+
continue
|
|
108
|
+
|
|
109
|
+
t = item.get("type")
|
|
110
|
+
|
|
111
|
+
if t == "stdout":
|
|
112
|
+
execution.stdout += item["data"]
|
|
113
|
+
on_stdout and on_stdout(item["data"])
|
|
114
|
+
|
|
115
|
+
elif t == "stderr":
|
|
116
|
+
execution.stderr += item["data"]
|
|
117
|
+
on_stderr and on_stderr(item["data"])
|
|
118
|
+
|
|
119
|
+
elif t == "result":
|
|
120
|
+
r = ResultItem(mime=item.get("mime", "text/plain"), data=item.get("data", ""))
|
|
121
|
+
execution.results.append(r)
|
|
122
|
+
on_result and on_result(r)
|
|
123
|
+
|
|
124
|
+
elif t == "error":
|
|
125
|
+
err = ExecutionError(
|
|
126
|
+
name=item.get("name", "Error"),
|
|
127
|
+
message=item.get("message", ""),
|
|
128
|
+
traceback=item.get("traceback", ""),
|
|
129
|
+
)
|
|
130
|
+
execution.error = err
|
|
131
|
+
on_error and on_error(err)
|
|
132
|
+
|
|
133
|
+
elif t == "done":
|
|
134
|
+
execution.execution_count = item.get("execution_count", 0)
|
|
135
|
+
break
|
|
136
|
+
|
|
137
|
+
return execution
|
|
138
|
+
|
|
139
|
+
def create_context(
|
|
140
|
+
self,
|
|
141
|
+
language: str = "python",
|
|
142
|
+
cwd: str = "/home/user",
|
|
143
|
+
) -> CodeContext:
|
|
144
|
+
"""Create a new isolated execution context."""
|
|
145
|
+
resp = httpx.post(
|
|
146
|
+
f"{self._server_url}/contexts",
|
|
147
|
+
json={"language": language, "cwd": cwd},
|
|
148
|
+
)
|
|
149
|
+
resp.raise_for_status()
|
|
150
|
+
data = resp.json()
|
|
151
|
+
return CodeContext(**data)
|
|
152
|
+
|
|
153
|
+
def list_contexts(self) -> list[CodeContext]:
|
|
154
|
+
"""List all active contexts in this sandbox."""
|
|
155
|
+
resp = httpx.get(f"{self._server_url}/contexts")
|
|
156
|
+
resp.raise_for_status()
|
|
157
|
+
return [CodeContext(**c) for c in resp.json()]
|
|
158
|
+
|
|
159
|
+
def delete_context(self, context: CodeContext | str) -> None:
|
|
160
|
+
"""Delete a context and free its resources."""
|
|
161
|
+
ctx_id = context if isinstance(context, str) else context.id
|
|
162
|
+
resp = httpx.delete(f"{self._server_url}/contexts/{ctx_id}")
|
|
163
|
+
resp.raise_for_status()
|
|
164
|
+
|
|
165
|
+
def restart_context(self, context: CodeContext | str) -> None:
|
|
166
|
+
"""Restart a context, clearing all variables and state."""
|
|
167
|
+
ctx_id = context if isinstance(context, str) else context.id
|
|
168
|
+
resp = httpx.post(f"{self._server_url}/contexts/{ctx_id}/restart")
|
|
169
|
+
resp.raise_for_status()
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from typing import Literal, Union
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class StdoutItem:
|
|
8
|
+
type: Literal["stdout"] = "stdout"
|
|
9
|
+
data: str = ""
|
|
10
|
+
ts: int = 0
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class StderrItem:
|
|
15
|
+
type: Literal["stderr"] = "stderr"
|
|
16
|
+
data: str = ""
|
|
17
|
+
ts: int = 0
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class ResultItem:
|
|
22
|
+
type: Literal["result"] = "result"
|
|
23
|
+
mime: str = "text/plain"
|
|
24
|
+
data: str = ""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class ErrorItem:
|
|
29
|
+
type: Literal["error"] = "error"
|
|
30
|
+
name: str = ""
|
|
31
|
+
message: str = ""
|
|
32
|
+
traceback: str = ""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
OutputItem = Union[StdoutItem, StderrItem, ResultItem, ErrorItem]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ExecutionError(Exception):
|
|
39
|
+
def __init__(self, name: str, message: str, traceback: str):
|
|
40
|
+
super().__init__(message)
|
|
41
|
+
self.name = name
|
|
42
|
+
self.message = message
|
|
43
|
+
self.traceback = traceback
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class Execution:
|
|
48
|
+
stdout: str = ""
|
|
49
|
+
stderr: str = ""
|
|
50
|
+
results: list[ResultItem] = field(default_factory=list)
|
|
51
|
+
error: ExecutionError | None = None
|
|
52
|
+
execution_count: int = 0
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def success(self) -> bool:
|
|
56
|
+
return self.error is None
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def text(self) -> str:
|
|
60
|
+
return self.stdout
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class CodeContext:
|
|
65
|
+
id: str
|
|
66
|
+
language: str
|
|
67
|
+
cwd: str
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
DEFAULT_API_URL = "https://api.lizard.run"
|
|
4
|
+
DEFAULT_SANDBOX_TIMEOUT_MS = 5 * 60 * 1000 # 5 minutes
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ConnectionConfig:
|
|
8
|
+
def __init__(
|
|
9
|
+
self,
|
|
10
|
+
api_key: str | None = None,
|
|
11
|
+
api_url: str | None = None,
|
|
12
|
+
timeout_ms: int | None = None,
|
|
13
|
+
):
|
|
14
|
+
self.api_key = api_key or os.environ.get("LIZARD_API_KEY", "")
|
|
15
|
+
self.api_url = api_url or os.environ.get("LIZARD_API_URL", DEFAULT_API_URL)
|
|
16
|
+
self.timeout_ms = timeout_ms or DEFAULT_SANDBOX_TIMEOUT_MS
|
|
17
|
+
|
|
18
|
+
if not self.api_key:
|
|
19
|
+
raise ValueError(
|
|
20
|
+
"Lizard API key is required. Set LIZARD_API_KEY env var or pass api_key."
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def headers(self) -> dict[str, str]:
|
|
25
|
+
return {
|
|
26
|
+
"X-API-Key": self.api_key,
|
|
27
|
+
"Content-Type": "application/json",
|
|
28
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
class LizardError(Exception):
|
|
2
|
+
pass
|
|
3
|
+
|
|
4
|
+
class AuthenticationError(LizardError):
|
|
5
|
+
pass
|
|
6
|
+
|
|
7
|
+
class NotFoundError(LizardError):
|
|
8
|
+
pass
|
|
9
|
+
|
|
10
|
+
class TimeoutError(LizardError):
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
def handle_api_error(status_code: int, message: str) -> None:
|
|
14
|
+
if status_code in (401, 403):
|
|
15
|
+
raise AuthenticationError(message)
|
|
16
|
+
if status_code == 404:
|
|
17
|
+
raise NotFoundError(message)
|
|
18
|
+
if status_code in (408, 504):
|
|
19
|
+
raise TimeoutError(message)
|
|
20
|
+
raise LizardError(f"API error {status_code}: {message}")
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from ..config import ConnectionConfig
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class FileInfo:
|
|
11
|
+
"""Metadata for a file or directory inside a Lizard sandbox microVM."""
|
|
12
|
+
|
|
13
|
+
name: str
|
|
14
|
+
path: str
|
|
15
|
+
type: str # 'file' | 'dir'
|
|
16
|
+
size: int
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Fs:
|
|
20
|
+
"""
|
|
21
|
+
Read and write files inside a Lizard sandbox microVM.
|
|
22
|
+
|
|
23
|
+
Access via ``sandbox.fs``.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, sandbox_id: str, config: "ConnectionConfig"):
|
|
27
|
+
self._sandbox_id = sandbox_id
|
|
28
|
+
self._config = config
|
|
29
|
+
|
|
30
|
+
def write(self, path: str, data: str | bytes, *, user: str | None = None) -> None:
|
|
31
|
+
"""
|
|
32
|
+
Write a file into the microVM filesystem.
|
|
33
|
+
|
|
34
|
+
Parent directories are created automatically if they don't exist.
|
|
35
|
+
|
|
36
|
+
:param path: Absolute path inside the microVM.
|
|
37
|
+
:param data: File contents — string or bytes.
|
|
38
|
+
:param user: Write as this Linux user (default: ``root``).
|
|
39
|
+
|
|
40
|
+
Example::
|
|
41
|
+
|
|
42
|
+
sandbox.fs.write("/app/index.js", 'console.log("hello")')
|
|
43
|
+
"""
|
|
44
|
+
import httpx
|
|
45
|
+
|
|
46
|
+
content = data if isinstance(data, str) else data.decode()
|
|
47
|
+
body: dict = {"path": path, "content": content}
|
|
48
|
+
if user:
|
|
49
|
+
body["user"] = user
|
|
50
|
+
|
|
51
|
+
res = httpx.post(
|
|
52
|
+
f"{self._config.api_url}/api/sandboxes/{self._sandbox_id}/files",
|
|
53
|
+
headers=self._config.headers,
|
|
54
|
+
json=body,
|
|
55
|
+
)
|
|
56
|
+
if not res.is_success:
|
|
57
|
+
from ..errors import handle_api_error
|
|
58
|
+
handle_api_error(res.status_code, res.text)
|
|
59
|
+
|
|
60
|
+
def read(self, path: str, *, user: str | None = None) -> str:
|
|
61
|
+
"""
|
|
62
|
+
Read a file from the microVM filesystem.
|
|
63
|
+
|
|
64
|
+
:param path: Absolute path inside the microVM.
|
|
65
|
+
:returns: File contents as a UTF-8 string.
|
|
66
|
+
|
|
67
|
+
Example::
|
|
68
|
+
|
|
69
|
+
content = sandbox.fs.read("/app/index.js")
|
|
70
|
+
"""
|
|
71
|
+
import httpx
|
|
72
|
+
|
|
73
|
+
params: dict = {"path": path}
|
|
74
|
+
if user:
|
|
75
|
+
params["user"] = user
|
|
76
|
+
|
|
77
|
+
res = httpx.get(
|
|
78
|
+
f"{self._config.api_url}/api/sandboxes/{self._sandbox_id}/files",
|
|
79
|
+
headers=self._config.headers,
|
|
80
|
+
params=params,
|
|
81
|
+
)
|
|
82
|
+
if not res.is_success:
|
|
83
|
+
from ..errors import handle_api_error
|
|
84
|
+
handle_api_error(res.status_code, res.text)
|
|
85
|
+
return res.text
|
|
86
|
+
|
|
87
|
+
def list(self, path: str, *, user: str | None = None) -> list[FileInfo]:
|
|
88
|
+
"""
|
|
89
|
+
List files and directories at a path inside the microVM.
|
|
90
|
+
|
|
91
|
+
:param path: Directory path to list.
|
|
92
|
+
|
|
93
|
+
Example::
|
|
94
|
+
|
|
95
|
+
entries = sandbox.fs.list("/app")
|
|
96
|
+
"""
|
|
97
|
+
import httpx
|
|
98
|
+
|
|
99
|
+
params: dict = {"path": path}
|
|
100
|
+
if user:
|
|
101
|
+
params["user"] = user
|
|
102
|
+
|
|
103
|
+
res = httpx.get(
|
|
104
|
+
f"{self._config.api_url}/api/sandboxes/{self._sandbox_id}/files/list",
|
|
105
|
+
headers=self._config.headers,
|
|
106
|
+
params=params,
|
|
107
|
+
)
|
|
108
|
+
if not res.is_success:
|
|
109
|
+
from ..errors import handle_api_error
|
|
110
|
+
handle_api_error(res.status_code, res.text)
|
|
111
|
+
|
|
112
|
+
return [FileInfo(**f) for f in res.json()]
|
|
113
|
+
|
|
114
|
+
def remove(self, path: str, *, user: str | None = None) -> None:
|
|
115
|
+
"""Remove a file or directory from the microVM filesystem."""
|
|
116
|
+
import httpx
|
|
117
|
+
|
|
118
|
+
body: dict = {"path": path}
|
|
119
|
+
if user:
|
|
120
|
+
body["user"] = user
|
|
121
|
+
|
|
122
|
+
res = httpx.request(
|
|
123
|
+
"DELETE",
|
|
124
|
+
f"{self._config.api_url}/api/sandboxes/{self._sandbox_id}/files",
|
|
125
|
+
headers=self._config.headers,
|
|
126
|
+
json=body,
|
|
127
|
+
)
|
|
128
|
+
if not res.is_success:
|
|
129
|
+
from ..errors import handle_api_error
|
|
130
|
+
handle_api_error(res.status_code, res.text)
|
|
131
|
+
|
|
132
|
+
def make_dir(self, path: str, *, user: str | None = None) -> None:
|
|
133
|
+
"""Create a directory (and any missing parents) inside the microVM."""
|
|
134
|
+
import httpx
|
|
135
|
+
import json
|
|
136
|
+
|
|
137
|
+
body: dict = {"cmd": f"mkdir -p {json.dumps(path)}"}
|
|
138
|
+
if user:
|
|
139
|
+
body["user"] = user
|
|
140
|
+
|
|
141
|
+
res = httpx.post(
|
|
142
|
+
f"{self._config.api_url}/api/sandboxes/{self._sandbox_id}/exec",
|
|
143
|
+
headers=self._config.headers,
|
|
144
|
+
json=body,
|
|
145
|
+
)
|
|
146
|
+
if not res.is_success:
|
|
147
|
+
from ..errors import handle_api_error
|
|
148
|
+
handle_api_error(res.status_code, res.text)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from ..config import ConnectionConfig
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class ProcessResult:
|
|
11
|
+
"""Result of a process executed inside a Lizard sandbox microVM."""
|
|
12
|
+
|
|
13
|
+
stdout: str
|
|
14
|
+
stderr: str
|
|
15
|
+
exit_code: int
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Process:
|
|
19
|
+
"""
|
|
20
|
+
Run processes inside a Lizard sandbox microVM.
|
|
21
|
+
|
|
22
|
+
Access via ``sandbox.process``.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, sandbox_id: str, config: "ConnectionConfig"):
|
|
26
|
+
self._sandbox_id = sandbox_id
|
|
27
|
+
self._config = config
|
|
28
|
+
|
|
29
|
+
def exec_(
|
|
30
|
+
self,
|
|
31
|
+
cmd: str,
|
|
32
|
+
*,
|
|
33
|
+
envs: dict[str, str] | None = None,
|
|
34
|
+
user: str | None = None,
|
|
35
|
+
workdir: str | None = None,
|
|
36
|
+
timeout_ms: int | None = None,
|
|
37
|
+
) -> ProcessResult:
|
|
38
|
+
"""
|
|
39
|
+
Execute a command inside the microVM and wait for it to finish.
|
|
40
|
+
|
|
41
|
+
The command runs in a shell inside the Lizard sandbox and returns
|
|
42
|
+
stdout, stderr, and the exit code when it completes.
|
|
43
|
+
|
|
44
|
+
:param cmd: Shell command to run inside the microVM.
|
|
45
|
+
:param envs: Additional environment variables for this execution.
|
|
46
|
+
:param user: Run as this Linux user (default: ``root``).
|
|
47
|
+
:param workdir: Working directory inside the microVM.
|
|
48
|
+
:param timeout_ms: Execution timeout in milliseconds.
|
|
49
|
+
|
|
50
|
+
Example::
|
|
51
|
+
|
|
52
|
+
result = sandbox.process.exec_("node index.js")
|
|
53
|
+
print(result.stdout)
|
|
54
|
+
|
|
55
|
+
Example with options::
|
|
56
|
+
|
|
57
|
+
result = sandbox.process.exec_(
|
|
58
|
+
"npm test",
|
|
59
|
+
workdir="/app",
|
|
60
|
+
envs={"NODE_ENV": "test"},
|
|
61
|
+
)
|
|
62
|
+
"""
|
|
63
|
+
import httpx
|
|
64
|
+
|
|
65
|
+
body: dict = {"cmd": cmd}
|
|
66
|
+
if envs:
|
|
67
|
+
body["envs"] = envs
|
|
68
|
+
if user:
|
|
69
|
+
body["user"] = user
|
|
70
|
+
if workdir:
|
|
71
|
+
body["workdir"] = workdir
|
|
72
|
+
if timeout_ms:
|
|
73
|
+
body["timeoutMs"] = timeout_ms
|
|
74
|
+
|
|
75
|
+
timeout = (timeout_ms or 60_000) / 1000
|
|
76
|
+
|
|
77
|
+
res = httpx.post(
|
|
78
|
+
f"{self._config.api_url}/api/sandboxes/{self._sandbox_id}/exec",
|
|
79
|
+
headers=self._config.headers,
|
|
80
|
+
json=body,
|
|
81
|
+
timeout=timeout,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
if not res.is_success:
|
|
85
|
+
from ..errors import handle_api_error
|
|
86
|
+
handle_api_error(res.status_code, res.text)
|
|
87
|
+
|
|
88
|
+
data = res.json()
|
|
89
|
+
return ProcessResult(
|
|
90
|
+
stdout=data.get("stdout", ""),
|
|
91
|
+
stderr=data.get("stderr", ""),
|
|
92
|
+
exit_code=data.get("exitCode", 0),
|
|
93
|
+
)
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from ..config import ConnectionConfig, DEFAULT_SANDBOX_TIMEOUT_MS
|
|
6
|
+
from .process import Process
|
|
7
|
+
from .fs import Fs
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class SandboxInfo:
|
|
12
|
+
sandbox_id: str
|
|
13
|
+
template: str
|
|
14
|
+
started_at: str
|
|
15
|
+
end_at: str
|
|
16
|
+
metadata: dict[str, str] | None = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Sandbox:
|
|
20
|
+
"""
|
|
21
|
+
A Lizard sandbox — an isolated Firecracker microVM that boots in milliseconds.
|
|
22
|
+
|
|
23
|
+
Each sandbox is a full Linux environment with its own filesystem, network,
|
|
24
|
+
and process namespace. Sandboxes are created from templates, can be paused
|
|
25
|
+
to a snapshot, and resumed instantly — ideal for stateful AI agent sessions
|
|
26
|
+
or ephemeral code execution.
|
|
27
|
+
|
|
28
|
+
Example::
|
|
29
|
+
|
|
30
|
+
from lizard import Sandbox
|
|
31
|
+
|
|
32
|
+
sandbox = Sandbox.create("node22")
|
|
33
|
+
sandbox.fs.write("/app/index.js", 'console.log("hello world")')
|
|
34
|
+
result = sandbox.process.exec_("node /app/index.js")
|
|
35
|
+
print(result.stdout) # "hello world"
|
|
36
|
+
sandbox.kill()
|
|
37
|
+
|
|
38
|
+
Can also be used as a context manager::
|
|
39
|
+
|
|
40
|
+
with Sandbox.create("python312") as sandbox:
|
|
41
|
+
sandbox.fs.write("/app/main.py", "print('done')")
|
|
42
|
+
sandbox.process.exec_("python /app/main.py")
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
_default_template = "base"
|
|
46
|
+
_default_timeout_ms = DEFAULT_SANDBOX_TIMEOUT_MS
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
sandbox_id: str,
|
|
51
|
+
*,
|
|
52
|
+
api_key: str | None = None,
|
|
53
|
+
api_url: str | None = None,
|
|
54
|
+
timeout_ms: int | None = None,
|
|
55
|
+
):
|
|
56
|
+
self.sandbox_id = sandbox_id
|
|
57
|
+
self._config = ConnectionConfig(api_key=api_key, api_url=api_url, timeout_ms=timeout_ms)
|
|
58
|
+
self.fs = Fs(self.sandbox_id, self._config)
|
|
59
|
+
self.process = Process(self.sandbox_id, self._config)
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
def create(
|
|
63
|
+
cls,
|
|
64
|
+
template: str | None = None,
|
|
65
|
+
*,
|
|
66
|
+
api_key: str | None = None,
|
|
67
|
+
api_url: str | None = None,
|
|
68
|
+
timeout_ms: int | None = None,
|
|
69
|
+
metadata: dict[str, str] | None = None,
|
|
70
|
+
envs: dict[str, str] | None = None,
|
|
71
|
+
) -> "Sandbox":
|
|
72
|
+
"""
|
|
73
|
+
Boot a new Lizard sandbox from the specified template.
|
|
74
|
+
|
|
75
|
+
Available templates include ``base``, ``node22``, and ``python312``.
|
|
76
|
+
Custom templates can be built and pushed via ``lizard push``.
|
|
77
|
+
|
|
78
|
+
:param template: Template name. Defaults to ``base``.
|
|
79
|
+
|
|
80
|
+
Example::
|
|
81
|
+
|
|
82
|
+
sandbox = Sandbox.create("node22")
|
|
83
|
+
sandbox = Sandbox.create() # uses 'base' template
|
|
84
|
+
"""
|
|
85
|
+
import httpx
|
|
86
|
+
|
|
87
|
+
config = ConnectionConfig(api_key=api_key, api_url=api_url, timeout_ms=timeout_ms)
|
|
88
|
+
effective_template = template or cls._default_template
|
|
89
|
+
effective_timeout = timeout_ms or cls._default_timeout_ms
|
|
90
|
+
|
|
91
|
+
body: dict[str, Any] = {"template": effective_template, "timeoutMs": effective_timeout}
|
|
92
|
+
if metadata:
|
|
93
|
+
body["metadata"] = metadata
|
|
94
|
+
if envs:
|
|
95
|
+
body["envs"] = envs
|
|
96
|
+
|
|
97
|
+
res = httpx.post(
|
|
98
|
+
f"{config.api_url}/api/sandboxes",
|
|
99
|
+
headers=config.headers,
|
|
100
|
+
json=body,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
if not res.is_success:
|
|
104
|
+
from ..errors import handle_api_error
|
|
105
|
+
handle_api_error(res.status_code, res.text)
|
|
106
|
+
|
|
107
|
+
data = res.json()
|
|
108
|
+
return cls(
|
|
109
|
+
data["sandboxId"],
|
|
110
|
+
api_key=api_key,
|
|
111
|
+
api_url=api_url,
|
|
112
|
+
timeout_ms=timeout_ms,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
@classmethod
|
|
116
|
+
def connect(
|
|
117
|
+
cls,
|
|
118
|
+
sandbox_id: str,
|
|
119
|
+
*,
|
|
120
|
+
api_key: str | None = None,
|
|
121
|
+
api_url: str | None = None,
|
|
122
|
+
) -> "Sandbox":
|
|
123
|
+
"""
|
|
124
|
+
Connect to an existing sandbox by its ID.
|
|
125
|
+
|
|
126
|
+
If the sandbox is currently paused, it is automatically resumed from
|
|
127
|
+
its last snapshot before this call returns.
|
|
128
|
+
|
|
129
|
+
Example::
|
|
130
|
+
|
|
131
|
+
sandbox = Sandbox.connect("sandbox_abc123")
|
|
132
|
+
"""
|
|
133
|
+
import httpx
|
|
134
|
+
|
|
135
|
+
config = ConnectionConfig(api_key=api_key, api_url=api_url)
|
|
136
|
+
res = httpx.post(
|
|
137
|
+
f"{config.api_url}/api/sandboxes/{sandbox_id}/resume",
|
|
138
|
+
headers=config.headers,
|
|
139
|
+
)
|
|
140
|
+
if res.status_code not in (200, 404):
|
|
141
|
+
from ..errors import handle_api_error
|
|
142
|
+
handle_api_error(res.status_code, res.text)
|
|
143
|
+
|
|
144
|
+
return cls(sandbox_id, api_key=api_key, api_url=api_url)
|
|
145
|
+
|
|
146
|
+
@classmethod
|
|
147
|
+
def list(cls, *, api_key: str | None = None, api_url: str | None = None) -> list[SandboxInfo]:
|
|
148
|
+
"""List all running sandboxes for the authenticated account."""
|
|
149
|
+
import httpx
|
|
150
|
+
|
|
151
|
+
config = ConnectionConfig(api_key=api_key, api_url=api_url)
|
|
152
|
+
res = httpx.get(f"{config.api_url}/api/sandboxes", headers=config.headers)
|
|
153
|
+
if not res.is_success:
|
|
154
|
+
from ..errors import handle_api_error
|
|
155
|
+
handle_api_error(res.status_code, res.text)
|
|
156
|
+
|
|
157
|
+
return [
|
|
158
|
+
SandboxInfo(
|
|
159
|
+
sandbox_id=s["sandboxId"],
|
|
160
|
+
template=s["template"],
|
|
161
|
+
started_at=s["startedAt"],
|
|
162
|
+
end_at=s["endAt"],
|
|
163
|
+
metadata=s.get("metadata"),
|
|
164
|
+
)
|
|
165
|
+
for s in res.json()
|
|
166
|
+
]
|
|
167
|
+
|
|
168
|
+
def kill(self) -> bool:
|
|
169
|
+
"""
|
|
170
|
+
Kill the sandbox and release its resources immediately.
|
|
171
|
+
|
|
172
|
+
:returns: ``True`` if the microVM was terminated, ``False`` if it was already gone.
|
|
173
|
+
"""
|
|
174
|
+
import httpx
|
|
175
|
+
|
|
176
|
+
res = httpx.delete(
|
|
177
|
+
f"{self._config.api_url}/api/sandboxes/{self.sandbox_id}",
|
|
178
|
+
headers=self._config.headers,
|
|
179
|
+
)
|
|
180
|
+
if res.status_code == 404:
|
|
181
|
+
return False
|
|
182
|
+
if not res.is_success:
|
|
183
|
+
from ..errors import handle_api_error
|
|
184
|
+
handle_api_error(res.status_code, res.text)
|
|
185
|
+
return True
|
|
186
|
+
|
|
187
|
+
def pause(self) -> bool:
|
|
188
|
+
"""
|
|
189
|
+
Snapshot and pause the sandbox microVM.
|
|
190
|
+
|
|
191
|
+
The sandbox state — memory, filesystem, and running processes — is
|
|
192
|
+
saved to a snapshot. Resume with :meth:`resume` or
|
|
193
|
+
:meth:`Sandbox.connect`.
|
|
194
|
+
|
|
195
|
+
:returns: ``True`` if paused successfully.
|
|
196
|
+
"""
|
|
197
|
+
import httpx
|
|
198
|
+
|
|
199
|
+
res = httpx.post(
|
|
200
|
+
f"{self._config.api_url}/api/sandboxes/{self.sandbox_id}/pause",
|
|
201
|
+
headers=self._config.headers,
|
|
202
|
+
)
|
|
203
|
+
if res.status_code == 404:
|
|
204
|
+
return False
|
|
205
|
+
if not res.is_success:
|
|
206
|
+
from ..errors import handle_api_error
|
|
207
|
+
handle_api_error(res.status_code, res.text)
|
|
208
|
+
return True
|
|
209
|
+
|
|
210
|
+
def resume(self) -> bool:
|
|
211
|
+
"""
|
|
212
|
+
Resume a paused sandbox from its last snapshot.
|
|
213
|
+
|
|
214
|
+
:returns: ``True`` if resumed successfully.
|
|
215
|
+
"""
|
|
216
|
+
import httpx
|
|
217
|
+
|
|
218
|
+
res = httpx.post(
|
|
219
|
+
f"{self._config.api_url}/api/sandboxes/{self.sandbox_id}/resume",
|
|
220
|
+
headers=self._config.headers,
|
|
221
|
+
)
|
|
222
|
+
if res.status_code == 404:
|
|
223
|
+
return False
|
|
224
|
+
if not res.is_success:
|
|
225
|
+
from ..errors import handle_api_error
|
|
226
|
+
handle_api_error(res.status_code, res.text)
|
|
227
|
+
return True
|
|
228
|
+
|
|
229
|
+
def set_timeout(self, timeout_ms: int) -> None:
|
|
230
|
+
"""Extend or reduce the sandbox timeout."""
|
|
231
|
+
import httpx
|
|
232
|
+
|
|
233
|
+
res = httpx.post(
|
|
234
|
+
f"{self._config.api_url}/api/sandboxes/{self.sandbox_id}/timeout",
|
|
235
|
+
headers=self._config.headers,
|
|
236
|
+
json={"timeoutMs": timeout_ms},
|
|
237
|
+
)
|
|
238
|
+
if not res.is_success:
|
|
239
|
+
from ..errors import handle_api_error
|
|
240
|
+
handle_api_error(res.status_code, res.text)
|
|
241
|
+
|
|
242
|
+
def get_info(self) -> SandboxInfo:
|
|
243
|
+
"""Get metadata and status information about this sandbox."""
|
|
244
|
+
import httpx
|
|
245
|
+
|
|
246
|
+
res = httpx.get(
|
|
247
|
+
f"{self._config.api_url}/api/sandboxes/{self.sandbox_id}",
|
|
248
|
+
headers=self._config.headers,
|
|
249
|
+
)
|
|
250
|
+
if not res.is_success:
|
|
251
|
+
from ..errors import handle_api_error
|
|
252
|
+
handle_api_error(res.status_code, res.text)
|
|
253
|
+
|
|
254
|
+
s = res.json()
|
|
255
|
+
return SandboxInfo(
|
|
256
|
+
sandbox_id=s["sandboxId"],
|
|
257
|
+
template=s["template"],
|
|
258
|
+
started_at=s["startedAt"],
|
|
259
|
+
end_at=s["endAt"],
|
|
260
|
+
metadata=s.get("metadata"),
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
def get_host(self, port: int) -> str:
|
|
264
|
+
"""
|
|
265
|
+
Get the public HTTPS URL for a port exposed inside the sandbox.
|
|
266
|
+
|
|
267
|
+
Useful for reaching HTTP servers started inside the microVM from your
|
|
268
|
+
agent or tests without any additional tunneling.
|
|
269
|
+
|
|
270
|
+
:param port: Port number the service is listening on inside the microVM.
|
|
271
|
+
|
|
272
|
+
Example::
|
|
273
|
+
|
|
274
|
+
sandbox.process.exec_("npx -y serve -p 3000 &")
|
|
275
|
+
url = sandbox.get_host(3000)
|
|
276
|
+
# https://{sandboxId}-3000.sandbox.lizard.run
|
|
277
|
+
"""
|
|
278
|
+
from urllib.parse import urlparse
|
|
279
|
+
api_host = urlparse(self._config.api_url).netloc
|
|
280
|
+
base_host = api_host.replace("api.", "", 1)
|
|
281
|
+
return f"{self.sandbox_id}-{port}.sandbox.{base_host}"
|
|
282
|
+
|
|
283
|
+
def __enter__(self) -> "Sandbox":
|
|
284
|
+
return self
|
|
285
|
+
|
|
286
|
+
def __exit__(self, *_: Any) -> None:
|
|
287
|
+
self.kill()
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "lizard-sdk"
|
|
7
|
+
version = "0.1.1"
|
|
8
|
+
description = "Lizard SDK — cloud sandboxes for AI agents"
|
|
9
|
+
readme = {file = "README.md", content-type = "text/markdown"}
|
|
10
|
+
license = { text = "Apache-2.0" }
|
|
11
|
+
requires-python = ">=3.8"
|
|
12
|
+
dependencies = [
|
|
13
|
+
"httpx>=0.24.0",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.optional-dependencies]
|
|
17
|
+
dev = ["pytest", "pytest-asyncio", "mypy"]
|
|
18
|
+
|
|
19
|
+
[tool.hatch.build.targets.wheel]
|
|
20
|
+
packages = ["lizard"]
|