cloudex 0.1.11__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.
- cloudex-0.1.11/.gitignore +19 -0
- cloudex-0.1.11/LICENSE +21 -0
- cloudex-0.1.11/PKG-INFO +93 -0
- cloudex-0.1.11/README.md +78 -0
- cloudex-0.1.11/pyproject.toml +24 -0
- cloudex-0.1.11/src/cloudex/__init__.py +441 -0
- cloudex-0.1.11/src/cloudex/py.typed +0 -0
- cloudex-0.1.11/tests/test_cloudex.py +212 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
node_modules/
|
|
2
|
+
dist/
|
|
3
|
+
.wrangler/
|
|
4
|
+
.dev.vars
|
|
5
|
+
.env*
|
|
6
|
+
!.env.example
|
|
7
|
+
*.log
|
|
8
|
+
.DS_Store
|
|
9
|
+
|
|
10
|
+
# Go build output
|
|
11
|
+
/go/bin/
|
|
12
|
+
|
|
13
|
+
# Generated by scripts/bundle-api.sh
|
|
14
|
+
/go/internal/deploy/assets/worker.js
|
|
15
|
+
/go/internal/deploy/assets/meta.json
|
|
16
|
+
|
|
17
|
+
# Python
|
|
18
|
+
__pycache__/
|
|
19
|
+
*.egg-info/
|
cloudex-0.1.11/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Luis Catacora
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
cloudex-0.1.11/PKG-INFO
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: cloudex
|
|
3
|
+
Version: 0.1.11
|
|
4
|
+
Summary: SDK for Cloudex: agent sandboxes in your own Cloudflare account
|
|
5
|
+
Project-URL: Homepage, https://cloudex.build
|
|
6
|
+
Project-URL: Source, https://github.com/lucataco/cloudex
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: agents,claude-code,cloudflare,codex,containers,sandbox
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Typing :: Typed
|
|
13
|
+
Requires-Python: >=3.9
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# Cloudex SDK for Python
|
|
17
|
+
|
|
18
|
+
Create, fork and drive [Cloudex](https://cloudex.build) sandboxes: agent
|
|
19
|
+
computers in your own Cloudflare account. Python 3.9+, standard library only.
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
23
|
+
from cloudex import Cloudex
|
|
24
|
+
|
|
25
|
+
cx = Cloudex() # the deployment saved by `cloudex login`
|
|
26
|
+
box = cx.create(name="web")
|
|
27
|
+
box.exec("git clone https://github.com/you/app && cd app && pip install -e .")
|
|
28
|
+
|
|
29
|
+
# Ten copies of that disk, each trying something different.
|
|
30
|
+
forks = box.fork(count=10, name="try")
|
|
31
|
+
with ThreadPoolExecutor(10) as pool:
|
|
32
|
+
results = list(pool.map(lambda f: f.exec(["pytest", "-x", "app"]), forks))
|
|
33
|
+
|
|
34
|
+
print(forks[0].host(8000)) # an HTTPS link to a port
|
|
35
|
+
for f in forks:
|
|
36
|
+
f.delete()
|
|
37
|
+
box.stop() # saves the disk; box.resume() picks up where it left off
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Agents
|
|
41
|
+
|
|
42
|
+
`box.prompt()` starts Claude Code, or Codex, on a task with no terminal. Each
|
|
43
|
+
message is one turn of the agent's own conversation, which lives on the
|
|
44
|
+
sandbox's disk.
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
agent = box.prompt("make the tests pass", cwd="app")
|
|
48
|
+
for e in agent.events():
|
|
49
|
+
# e["type"] is prompt, event (the agent's own JSON in e["event"]), output or end
|
|
50
|
+
if e["type"] == "event":
|
|
51
|
+
print(e["event"])
|
|
52
|
+
print(agent.last_message, agent.error)
|
|
53
|
+
|
|
54
|
+
agent.send("now update the docs") # waits for a running turn
|
|
55
|
+
agent.send("stop, fix the build first", interrupt=True)
|
|
56
|
+
agent.wait()
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`events()` waits up to 25 seconds per request, so give `Cloudex(timeout=)` more
|
|
60
|
+
than that if you set one.
|
|
61
|
+
|
|
62
|
+
## Connecting
|
|
63
|
+
|
|
64
|
+
`Cloudex(api_url, api_key)` takes the API URL and key that `cloudex deploy`
|
|
65
|
+
printed (`cloudex status --show-key` shows them again). Without them it reads
|
|
66
|
+
`$CLOUDEX_API_URL` and `$CLOUDEX_API_KEY`, then the deployment the CLI saved.
|
|
67
|
+
The key gives full control of your sandboxes.
|
|
68
|
+
|
|
69
|
+
## API
|
|
70
|
+
|
|
71
|
+
| Call | Does |
|
|
72
|
+
|---|---|
|
|
73
|
+
| `cx.create(name=, size=, idle_minutes=, env=, files=, repo=)` | Create a sandbox; returns once it's running (and the repo is cloned) |
|
|
74
|
+
| `cx.get(ref)`, `cx.list()`, `cx.health()` | Look up by id or name, list, check the deployment |
|
|
75
|
+
| `box.exec(cmd, root=, cwd=, timeout_sec=)` | Run shell code, or an argument list, and wait; check `exit_code` |
|
|
76
|
+
| `box.fork(count=, name=, size=, idle_minutes=)` | Up to 10 sandboxes starting from a copy of the disk |
|
|
77
|
+
| `box.host(port)` | HTTPS link to a port on your preview domain |
|
|
78
|
+
| `box.desktop(size=)`, `box.stop_desktop()` | Start the desktop (Chrome, a terminal) and get a link to it |
|
|
79
|
+
| `box.stop()`, `box.resume(from_backup=)`, `box.delete()` | Save and stop, start again, delete |
|
|
80
|
+
| `box.refresh()` | Fetch the latest state; `box.id`, `box.name`, `box.state` and `box.info` hold it |
|
|
81
|
+
| `box.settings()`, `box.update_settings(env=, files=)` | Environment variables and files the sandbox gets at every start; `None` removes one |
|
|
82
|
+
| `cx.set_key(provider, key)`, `cx.list_keys()`, `cx.delete_key(provider)` | AI provider keys sandboxes use without holding them (`"anthropic"`, `"openai"`) |
|
|
83
|
+
| `box.prompt(prompt, agent=, cwd=, model=)` | Start Claude Code (default) or Codex on a task; returns an `Agent` at once |
|
|
84
|
+
| `box.agents()`, `box.agent(id)` | The sandbox's agents, newest first; one by id |
|
|
85
|
+
| `agent.events(after=, follow=)` | Iterate over its events (dicts); follows until the agent is idle |
|
|
86
|
+
| `agent.send(prompt, interrupt=)`, `agent.interrupt()`, `agent.wait()` | Continue, stop the running turn, wait until idle |
|
|
87
|
+
|
|
88
|
+
Errors from the API raise `CloudexError`, with `status` and the API's message.
|
|
89
|
+
The full API is described at `<your API URL>/v1/openapi.json`.
|
|
90
|
+
|
|
91
|
+
Fetching a preview link with `urllib` needs a `User-Agent` header: Cloudflare's
|
|
92
|
+
Browser Integrity Check, on by default for your domain, answers urllib's default
|
|
93
|
+
one with error 1010.
|
cloudex-0.1.11/README.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# Cloudex SDK for Python
|
|
2
|
+
|
|
3
|
+
Create, fork and drive [Cloudex](https://cloudex.build) sandboxes: agent
|
|
4
|
+
computers in your own Cloudflare account. Python 3.9+, standard library only.
|
|
5
|
+
|
|
6
|
+
```python
|
|
7
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
8
|
+
from cloudex import Cloudex
|
|
9
|
+
|
|
10
|
+
cx = Cloudex() # the deployment saved by `cloudex login`
|
|
11
|
+
box = cx.create(name="web")
|
|
12
|
+
box.exec("git clone https://github.com/you/app && cd app && pip install -e .")
|
|
13
|
+
|
|
14
|
+
# Ten copies of that disk, each trying something different.
|
|
15
|
+
forks = box.fork(count=10, name="try")
|
|
16
|
+
with ThreadPoolExecutor(10) as pool:
|
|
17
|
+
results = list(pool.map(lambda f: f.exec(["pytest", "-x", "app"]), forks))
|
|
18
|
+
|
|
19
|
+
print(forks[0].host(8000)) # an HTTPS link to a port
|
|
20
|
+
for f in forks:
|
|
21
|
+
f.delete()
|
|
22
|
+
box.stop() # saves the disk; box.resume() picks up where it left off
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Agents
|
|
26
|
+
|
|
27
|
+
`box.prompt()` starts Claude Code, or Codex, on a task with no terminal. Each
|
|
28
|
+
message is one turn of the agent's own conversation, which lives on the
|
|
29
|
+
sandbox's disk.
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
agent = box.prompt("make the tests pass", cwd="app")
|
|
33
|
+
for e in agent.events():
|
|
34
|
+
# e["type"] is prompt, event (the agent's own JSON in e["event"]), output or end
|
|
35
|
+
if e["type"] == "event":
|
|
36
|
+
print(e["event"])
|
|
37
|
+
print(agent.last_message, agent.error)
|
|
38
|
+
|
|
39
|
+
agent.send("now update the docs") # waits for a running turn
|
|
40
|
+
agent.send("stop, fix the build first", interrupt=True)
|
|
41
|
+
agent.wait()
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`events()` waits up to 25 seconds per request, so give `Cloudex(timeout=)` more
|
|
45
|
+
than that if you set one.
|
|
46
|
+
|
|
47
|
+
## Connecting
|
|
48
|
+
|
|
49
|
+
`Cloudex(api_url, api_key)` takes the API URL and key that `cloudex deploy`
|
|
50
|
+
printed (`cloudex status --show-key` shows them again). Without them it reads
|
|
51
|
+
`$CLOUDEX_API_URL` and `$CLOUDEX_API_KEY`, then the deployment the CLI saved.
|
|
52
|
+
The key gives full control of your sandboxes.
|
|
53
|
+
|
|
54
|
+
## API
|
|
55
|
+
|
|
56
|
+
| Call | Does |
|
|
57
|
+
|---|---|
|
|
58
|
+
| `cx.create(name=, size=, idle_minutes=, env=, files=, repo=)` | Create a sandbox; returns once it's running (and the repo is cloned) |
|
|
59
|
+
| `cx.get(ref)`, `cx.list()`, `cx.health()` | Look up by id or name, list, check the deployment |
|
|
60
|
+
| `box.exec(cmd, root=, cwd=, timeout_sec=)` | Run shell code, or an argument list, and wait; check `exit_code` |
|
|
61
|
+
| `box.fork(count=, name=, size=, idle_minutes=)` | Up to 10 sandboxes starting from a copy of the disk |
|
|
62
|
+
| `box.host(port)` | HTTPS link to a port on your preview domain |
|
|
63
|
+
| `box.desktop(size=)`, `box.stop_desktop()` | Start the desktop (Chrome, a terminal) and get a link to it |
|
|
64
|
+
| `box.stop()`, `box.resume(from_backup=)`, `box.delete()` | Save and stop, start again, delete |
|
|
65
|
+
| `box.refresh()` | Fetch the latest state; `box.id`, `box.name`, `box.state` and `box.info` hold it |
|
|
66
|
+
| `box.settings()`, `box.update_settings(env=, files=)` | Environment variables and files the sandbox gets at every start; `None` removes one |
|
|
67
|
+
| `cx.set_key(provider, key)`, `cx.list_keys()`, `cx.delete_key(provider)` | AI provider keys sandboxes use without holding them (`"anthropic"`, `"openai"`) |
|
|
68
|
+
| `box.prompt(prompt, agent=, cwd=, model=)` | Start Claude Code (default) or Codex on a task; returns an `Agent` at once |
|
|
69
|
+
| `box.agents()`, `box.agent(id)` | The sandbox's agents, newest first; one by id |
|
|
70
|
+
| `agent.events(after=, follow=)` | Iterate over its events (dicts); follows until the agent is idle |
|
|
71
|
+
| `agent.send(prompt, interrupt=)`, `agent.interrupt()`, `agent.wait()` | Continue, stop the running turn, wait until idle |
|
|
72
|
+
|
|
73
|
+
Errors from the API raise `CloudexError`, with `status` and the API's message.
|
|
74
|
+
The full API is described at `<your API URL>/v1/openapi.json`.
|
|
75
|
+
|
|
76
|
+
Fetching a preview link with `urllib` needs a `User-Agent` header: Cloudflare's
|
|
77
|
+
Browser Integrity Check, on by default for your domain, answers urllib's default
|
|
78
|
+
one with error 1010.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "cloudex"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "SDK for Cloudex: agent sandboxes in your own Cloudflare account"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
keywords = ["sandbox", "agents", "cloudflare", "containers", "claude-code", "codex"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Operating System :: OS Independent",
|
|
16
|
+
"Typing :: Typed",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.urls]
|
|
20
|
+
Homepage = "https://cloudex.build"
|
|
21
|
+
Source = "https://github.com/lucataco/cloudex"
|
|
22
|
+
|
|
23
|
+
[tool.hatch.version]
|
|
24
|
+
path = "src/cloudex/__init__.py"
|
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
"""The Cloudex SDK: create, fork and drive sandboxes from Python.
|
|
2
|
+
|
|
3
|
+
It talks to the /v1 API that `cloudex deploy` set up in your Cloudflare
|
|
4
|
+
account, using only the standard library.
|
|
5
|
+
|
|
6
|
+
from cloudex import Cloudex
|
|
7
|
+
|
|
8
|
+
cx = Cloudex() # the deployment saved by `cloudex login`, or $CLOUDEX_API_URL and $CLOUDEX_API_KEY
|
|
9
|
+
box = cx.create(name="web")
|
|
10
|
+
print(box.exec("uname -a").stdout)
|
|
11
|
+
forks = box.fork(count=3, name="try")
|
|
12
|
+
|
|
13
|
+
agent = box.prompt("make the tests pass") # Claude Code, with no terminal
|
|
14
|
+
for event in agent.events():
|
|
15
|
+
print(event["type"])
|
|
16
|
+
print(agent.last_message)
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import shlex
|
|
24
|
+
import urllib.error
|
|
25
|
+
import urllib.parse
|
|
26
|
+
import urllib.request
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any, Dict, Iterator, List, Optional, Sequence, Union
|
|
30
|
+
|
|
31
|
+
__all__ = ["Agent", "Cloudex", "CloudexError", "Desktop", "ExecResult", "Health", "ProviderKey", "Sandbox", "Settings"]
|
|
32
|
+
__version__ = "0.1.11"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class CloudexError(Exception):
|
|
36
|
+
"""An error from the API, with its HTTP status (0 when there was no request)."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, status: int, message: str):
|
|
39
|
+
super().__init__(message)
|
|
40
|
+
self.status = status
|
|
41
|
+
self.message = message
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class ExecResult:
|
|
46
|
+
exit_code: int
|
|
47
|
+
stdout: str # each stream is cut at 1 MiB
|
|
48
|
+
stderr: str
|
|
49
|
+
timed_out: bool
|
|
50
|
+
ms: int
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class Desktop:
|
|
55
|
+
url: str # the viewer, on the preview domain; anyone with it can use the desktop
|
|
56
|
+
port: int
|
|
57
|
+
path: str
|
|
58
|
+
size: str
|
|
59
|
+
installed: bool # this call installed it (the first start takes a minute or two)
|
|
60
|
+
started: bool # this call started it, rather than finding it running
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class ProviderKey:
|
|
65
|
+
provider: str # anthropic or openai
|
|
66
|
+
hint: str # the key's last 4 characters; the key itself is never returned
|
|
67
|
+
updated_at: int # milliseconds since the epoch
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class Settings:
|
|
72
|
+
"""A sandbox's settings. Values and file contents never come back."""
|
|
73
|
+
|
|
74
|
+
env: List[str]
|
|
75
|
+
files: List[str] # as ~/path
|
|
76
|
+
repo: Optional[Dict[str, Any]] # url, dir, branch
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True)
|
|
80
|
+
class Health:
|
|
81
|
+
ok: bool
|
|
82
|
+
version: str
|
|
83
|
+
preview_zone: Optional[str]
|
|
84
|
+
backups: bool
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _drop_none(**fields: Any) -> Dict[str, Any]:
|
|
88
|
+
return {k: v for k, v in fields.items() if v is not None}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _saved_deployment(config_dir: Optional[str]) -> Dict[str, Any]:
|
|
92
|
+
# The CLI keeps its deployment in ~/.config/cloudex/config.json.
|
|
93
|
+
path = Path(config_dir) if config_dir else Path.home() / ".config" / "cloudex"
|
|
94
|
+
try:
|
|
95
|
+
return json.loads((path / "config.json").read_text())
|
|
96
|
+
except (OSError, ValueError):
|
|
97
|
+
return {}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class Cloudex:
|
|
101
|
+
"""A Cloudex deployment.
|
|
102
|
+
|
|
103
|
+
The URL and key come from the arguments, then $CLOUDEX_API_URL and
|
|
104
|
+
$CLOUDEX_API_KEY, then the deployment saved by `cloudex deploy` or
|
|
105
|
+
`cloudex login`, the same order as the CLI. timeout is in seconds per
|
|
106
|
+
request; by default there's none, since starting or forking can take
|
|
107
|
+
minutes the first time on a host.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
def __init__(self, api_url: Optional[str] = None, api_key: Optional[str] = None, timeout: Optional[float] = None):
|
|
111
|
+
url = api_url or os.environ.get("CLOUDEX_API_URL")
|
|
112
|
+
key = api_key or os.environ.get("CLOUDEX_API_KEY")
|
|
113
|
+
if not url or not key:
|
|
114
|
+
saved = _saved_deployment(os.environ.get("CLOUDEX_CONFIG_DIR"))
|
|
115
|
+
url = url or saved.get("apiUrl")
|
|
116
|
+
key = key or saved.get("apiKey")
|
|
117
|
+
if not url:
|
|
118
|
+
raise CloudexError(
|
|
119
|
+
0,
|
|
120
|
+
"no Cloudex deployment: pass api_url and api_key, set CLOUDEX_API_URL and CLOUDEX_API_KEY, or run cloudex login",
|
|
121
|
+
)
|
|
122
|
+
self.api_url = url.rstrip("/")
|
|
123
|
+
self._key = key or ""
|
|
124
|
+
self._timeout = timeout
|
|
125
|
+
|
|
126
|
+
def __repr__(self) -> str:
|
|
127
|
+
return f"Cloudex({self.api_url!r})"
|
|
128
|
+
|
|
129
|
+
def health(self) -> Health:
|
|
130
|
+
"""The deployment's version and features."""
|
|
131
|
+
h = self._call("GET", "/v1/health")
|
|
132
|
+
return Health(ok=h["ok"], version=h["version"], preview_zone=h.get("previewZone"), backups=bool(h.get("backups")))
|
|
133
|
+
|
|
134
|
+
def list(self) -> List["Sandbox"]:
|
|
135
|
+
"""All sandboxes, newest first."""
|
|
136
|
+
return [Sandbox(self, info) for info in self._call("GET", "/v1/sandboxes")]
|
|
137
|
+
|
|
138
|
+
def get(self, ref: str) -> "Sandbox":
|
|
139
|
+
"""A sandbox by id (cx-xxxxxx) or name."""
|
|
140
|
+
return Sandbox(self, self._call("GET", _sandbox_path(ref)))
|
|
141
|
+
|
|
142
|
+
def create(
|
|
143
|
+
self,
|
|
144
|
+
name: Optional[str] = None,
|
|
145
|
+
size: Optional[str] = None,
|
|
146
|
+
idle_minutes: Optional[int] = None,
|
|
147
|
+
env: Optional[Dict[str, str]] = None,
|
|
148
|
+
files: Optional[Dict[str, str]] = None,
|
|
149
|
+
repo: Union[str, Dict[str, str], None] = None,
|
|
150
|
+
) -> "Sandbox":
|
|
151
|
+
"""Creates a sandbox and returns once it's running, usually in about 10 seconds.
|
|
152
|
+
|
|
153
|
+
size is standard-1 to standard-4 (default standard-2). idle_minutes
|
|
154
|
+
(default 30) is how long without activity before it saves and stops;
|
|
155
|
+
0 waits for the 6-hour platform limit. env and files (paths in the home
|
|
156
|
+
directory, such as ~/.npmrc) are applied at every start; repo, an
|
|
157
|
+
https:// URL with #branch if wanted, or {"url", "dir", "branch"}, is
|
|
158
|
+
cloned into the home directory. A failed clone leaves a notice.
|
|
159
|
+
"""
|
|
160
|
+
body = _drop_none(name=name, size=size, idleMinutes=idle_minutes, env=env, files=files, repo=repo)
|
|
161
|
+
return Sandbox(self, self._call("POST", "/v1/sandboxes", body))
|
|
162
|
+
|
|
163
|
+
def list_keys(self) -> List[ProviderKey]:
|
|
164
|
+
"""The AI provider keys sandboxes use; the keys themselves are never returned."""
|
|
165
|
+
return [_provider_key(k) for k in self._call("GET", "/v1/keys")]
|
|
166
|
+
|
|
167
|
+
def set_key(self, provider: str, key: str) -> ProviderKey:
|
|
168
|
+
"""Sets a provider's key ("anthropic" or "openai").
|
|
169
|
+
|
|
170
|
+
Sandboxes started from then on get a placeholder, and the real key is
|
|
171
|
+
added to their HTTPS calls to the provider on the way out, so it never
|
|
172
|
+
enters a sandbox, its saves, backups or forks. A changed key reaches
|
|
173
|
+
running sandboxes within a minute.
|
|
174
|
+
"""
|
|
175
|
+
return _provider_key(self._call("PUT", "/v1/keys/" + urllib.parse.quote(provider, safe=""), {"key": key}))
|
|
176
|
+
|
|
177
|
+
def delete_key(self, provider: str) -> None:
|
|
178
|
+
self._call("DELETE", "/v1/keys/" + urllib.parse.quote(provider, safe=""))
|
|
179
|
+
|
|
180
|
+
def _call(self, method: str, path: str, body: Optional[Dict[str, Any]] = None) -> Any:
|
|
181
|
+
data = None if body is None else json.dumps(body).encode()
|
|
182
|
+
req = urllib.request.Request(self.api_url + path, data=data, method=method)
|
|
183
|
+
req.add_header("Authorization", f"Bearer {self._key}")
|
|
184
|
+
req.add_header("User-Agent", f"cloudex-python/{__version__}")
|
|
185
|
+
if data is not None:
|
|
186
|
+
req.add_header("Content-Type", "application/json")
|
|
187
|
+
try:
|
|
188
|
+
with urllib.request.urlopen(req, timeout=self._timeout) as res:
|
|
189
|
+
text = res.read().decode()
|
|
190
|
+
except urllib.error.HTTPError as e:
|
|
191
|
+
text = e.read().decode(errors="replace")
|
|
192
|
+
try:
|
|
193
|
+
message = json.loads(text)["error"]
|
|
194
|
+
except (ValueError, KeyError, TypeError):
|
|
195
|
+
message = text.strip() or e.reason
|
|
196
|
+
raise CloudexError(e.code, str(message)) from None
|
|
197
|
+
except urllib.error.URLError as e:
|
|
198
|
+
raise CloudexError(0, f"can't reach {self.api_url}: {e.reason}") from None
|
|
199
|
+
return json.loads(text) if text else None
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _provider_key(k: Dict[str, Any]) -> ProviderKey:
|
|
203
|
+
return ProviderKey(provider=k["provider"], hint=k["hint"], updated_at=k["updatedAt"])
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _settings(s: Dict[str, Any]) -> Settings:
|
|
207
|
+
return Settings(env=s["env"], files=s["files"], repo=s.get("repo"))
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _sandbox_path(ref: str) -> str:
|
|
211
|
+
return "/v1/sandboxes/" + urllib.parse.quote(ref, safe="")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class Sandbox:
|
|
215
|
+
"""A sandbox. Its fields are as of the last call that returned it; refresh() updates them.
|
|
216
|
+
|
|
217
|
+
info holds everything the API reports (camelCase keys, times in
|
|
218
|
+
milliseconds since the epoch); id, name and state are the common ones.
|
|
219
|
+
"""
|
|
220
|
+
|
|
221
|
+
def __init__(self, client: Cloudex, info: Dict[str, Any]):
|
|
222
|
+
self._client = client
|
|
223
|
+
self.info = info
|
|
224
|
+
|
|
225
|
+
@property
|
|
226
|
+
def id(self) -> str:
|
|
227
|
+
return self.info["id"]
|
|
228
|
+
|
|
229
|
+
@property
|
|
230
|
+
def name(self) -> Optional[str]:
|
|
231
|
+
return self.info.get("name")
|
|
232
|
+
|
|
233
|
+
@property
|
|
234
|
+
def state(self) -> str:
|
|
235
|
+
return self.info["state"]
|
|
236
|
+
|
|
237
|
+
@property
|
|
238
|
+
def forked_from(self) -> Optional[str]:
|
|
239
|
+
return self.info.get("forkedFrom")
|
|
240
|
+
|
|
241
|
+
@property
|
|
242
|
+
def provider_keys(self) -> List[str]:
|
|
243
|
+
"""Providers whose keys this run uses without holding them (see Cloudex.set_key)."""
|
|
244
|
+
return self.info.get("providerKeys") or []
|
|
245
|
+
|
|
246
|
+
def __repr__(self) -> str:
|
|
247
|
+
label = f" {self.name!r}" if self.name else ""
|
|
248
|
+
return f"<Sandbox {self.id}{label} {self.state}>"
|
|
249
|
+
|
|
250
|
+
def _path(self, suffix: str = "") -> str:
|
|
251
|
+
return _sandbox_path(self.id) + suffix
|
|
252
|
+
|
|
253
|
+
def _update(self, method: str, suffix: str, body: Optional[Dict[str, Any]] = None) -> "Sandbox":
|
|
254
|
+
self.info = self._client._call(method, self._path(suffix), body)
|
|
255
|
+
return self
|
|
256
|
+
|
|
257
|
+
def refresh(self) -> "Sandbox":
|
|
258
|
+
return self._update("GET", "")
|
|
259
|
+
|
|
260
|
+
def stop(self) -> "Sandbox":
|
|
261
|
+
"""Saves the disk, backs up /home and stops."""
|
|
262
|
+
return self._update("POST", "/stop")
|
|
263
|
+
|
|
264
|
+
def resume(self, from_backup: bool = False) -> "Sandbox":
|
|
265
|
+
"""Starts from the save, or with from_backup, from a fresh image with /home restored."""
|
|
266
|
+
return self._update("POST", "/resume", {"fromBackup": True} if from_backup else None)
|
|
267
|
+
|
|
268
|
+
def delete(self) -> None:
|
|
269
|
+
"""Deletes the sandbox and its backups."""
|
|
270
|
+
self._client._call("DELETE", self._path())
|
|
271
|
+
|
|
272
|
+
def exec(
|
|
273
|
+
self,
|
|
274
|
+
command: Union[str, Sequence[str]],
|
|
275
|
+
root: bool = False,
|
|
276
|
+
cwd: Optional[str] = None,
|
|
277
|
+
timeout_sec: Optional[int] = None,
|
|
278
|
+
) -> ExecResult:
|
|
279
|
+
"""Runs a command with `bash -lc` and waits for it (default limit 600 s, at most 3600).
|
|
280
|
+
|
|
281
|
+
A string is run as shell code; a list is quoted, one argument per
|
|
282
|
+
element. A failing command doesn't raise: check exit_code.
|
|
283
|
+
"""
|
|
284
|
+
cmd = command if isinstance(command, str) else shlex.join(command)
|
|
285
|
+
body = _drop_none(cmd=cmd, root=root or None, cwd=cwd, timeoutSec=timeout_sec)
|
|
286
|
+
r = self._client._call("POST", self._path("/exec"), body)
|
|
287
|
+
return ExecResult(exit_code=r["exitCode"], stdout=r["stdout"], stderr=r["stderr"], timed_out=r["timedOut"], ms=r["ms"])
|
|
288
|
+
|
|
289
|
+
def fork(
|
|
290
|
+
self,
|
|
291
|
+
count: int = 1,
|
|
292
|
+
name: Optional[str] = None,
|
|
293
|
+
size: Optional[str] = None,
|
|
294
|
+
idle_minutes: Optional[int] = None,
|
|
295
|
+
) -> List["Sandbox"]:
|
|
296
|
+
"""Makes sandboxes (1 to 10) that start from a copy of this one's whole disk.
|
|
297
|
+
|
|
298
|
+
A running sandbox is saved first, in seconds, and keeps running. With
|
|
299
|
+
count above 1, name is the start of <name>-1, <name>-2 and so on.
|
|
300
|
+
Size and idle minutes default to this sandbox's. Forks that failed to
|
|
301
|
+
start come back in state "error"; resume() tries again.
|
|
302
|
+
"""
|
|
303
|
+
body = _drop_none(name=name, count=count, size=size, idleMinutes=idle_minutes)
|
|
304
|
+
return [Sandbox(self._client, info) for info in self._client._call("POST", self._path("/fork"), body)]
|
|
305
|
+
|
|
306
|
+
def host(self, port: int) -> str:
|
|
307
|
+
"""An HTTPS link to a port, on the deployment's preview domain. Anyone with it can use it."""
|
|
308
|
+
return self._client._call("POST", self._path("/hosts"), {"port": port})["url"]
|
|
309
|
+
|
|
310
|
+
def desktop(self, size: Optional[str] = None) -> Desktop:
|
|
311
|
+
"""Starts the desktop (Chrome and a terminal), or resizes a running one, and links to its viewer.
|
|
312
|
+
|
|
313
|
+
The first start installs it, which takes a minute or two.
|
|
314
|
+
"""
|
|
315
|
+
d = self._client._call("POST", self._path("/desktop"), _drop_none(size=size))
|
|
316
|
+
return Desktop(
|
|
317
|
+
url=f"{self.host(d['port'])}/{d['path']}",
|
|
318
|
+
port=d["port"],
|
|
319
|
+
path=d["path"],
|
|
320
|
+
size=d["size"],
|
|
321
|
+
installed=d["installed"],
|
|
322
|
+
started=d["started"],
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
def stop_desktop(self) -> None:
|
|
326
|
+
self._client._call("DELETE", self._path("/desktop"))
|
|
327
|
+
|
|
328
|
+
def settings(self) -> Settings:
|
|
329
|
+
"""The sandbox's environment variable names, files and repo."""
|
|
330
|
+
return _settings(self._client._call("GET", self._path("/settings")))
|
|
331
|
+
|
|
332
|
+
def update_settings(
|
|
333
|
+
self, env: Optional[Dict[str, Optional[str]]] = None, files: Optional[Dict[str, Optional[str]]] = None
|
|
334
|
+
) -> Settings:
|
|
335
|
+
"""Sets, or with None removes, environment variables and files.
|
|
336
|
+
|
|
337
|
+
A running sandbox gets the change at once, in new shells; a stopped one
|
|
338
|
+
when it next starts.
|
|
339
|
+
"""
|
|
340
|
+
return _settings(self._client._call("PATCH", self._path("/settings"), _drop_none(env=env, files=files)))
|
|
341
|
+
|
|
342
|
+
def prompt(
|
|
343
|
+
self,
|
|
344
|
+
prompt: str,
|
|
345
|
+
agent: Optional[str] = None,
|
|
346
|
+
cwd: Optional[str] = None,
|
|
347
|
+
model: Optional[str] = None,
|
|
348
|
+
) -> "Agent":
|
|
349
|
+
"""Starts Claude Code (or agent="codex") on a task, with no terminal, and returns at once.
|
|
350
|
+
|
|
351
|
+
Follow it with events() or wait(), and continue the conversation with
|
|
352
|
+
send(). cwd defaults to the home directory, model to the agent's own.
|
|
353
|
+
With provider keys set, it needs no sign-in.
|
|
354
|
+
"""
|
|
355
|
+
info = self._client._call("POST", self._path("/agents"), _drop_none(prompt=prompt, agent=agent, cwd=cwd, model=model))
|
|
356
|
+
return Agent(self._client, self._path("/agents/" + info["id"]), info)
|
|
357
|
+
|
|
358
|
+
def agents(self) -> List["Agent"]:
|
|
359
|
+
"""The agents started in this sandbox, newest first."""
|
|
360
|
+
return [Agent(self._client, self._path("/agents/" + i["id"]), i) for i in self._client._call("GET", self._path("/agents"))]
|
|
361
|
+
|
|
362
|
+
def agent(self, id: str) -> "Agent":
|
|
363
|
+
"""An agent by id (ag-xxxxxxxx)."""
|
|
364
|
+
path = self._path("/agents/" + urllib.parse.quote(id, safe=""))
|
|
365
|
+
return Agent(self._client, path, self._client._call("GET", path))
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
class Agent:
|
|
369
|
+
"""Claude Code or Codex working on a task in a sandbox.
|
|
370
|
+
|
|
371
|
+
Its fields are as of the last call that returned it; refresh(), events()
|
|
372
|
+
and wait() update them. info holds everything the API reports.
|
|
373
|
+
"""
|
|
374
|
+
|
|
375
|
+
def __init__(self, client: Cloudex, path: str, info: Dict[str, Any]):
|
|
376
|
+
self._client = client
|
|
377
|
+
self._path = path
|
|
378
|
+
self.info = info
|
|
379
|
+
|
|
380
|
+
@property
|
|
381
|
+
def id(self) -> str:
|
|
382
|
+
return self.info["id"]
|
|
383
|
+
|
|
384
|
+
@property
|
|
385
|
+
def state(self) -> str:
|
|
386
|
+
"""running while a turn is in progress or messages are queued, idle otherwise."""
|
|
387
|
+
return self.info["state"]
|
|
388
|
+
|
|
389
|
+
@property
|
|
390
|
+
def last_message(self) -> Optional[str]:
|
|
391
|
+
"""The agent's final message in the last finished turn."""
|
|
392
|
+
return self.info.get("lastMessage")
|
|
393
|
+
|
|
394
|
+
@property
|
|
395
|
+
def error(self) -> Optional[str]:
|
|
396
|
+
"""Why the last turn failed."""
|
|
397
|
+
return self.info.get("error")
|
|
398
|
+
|
|
399
|
+
def __repr__(self) -> str:
|
|
400
|
+
return f"<Agent {self.id} {self.info.get('agent')} {self.state}>"
|
|
401
|
+
|
|
402
|
+
def refresh(self) -> "Agent":
|
|
403
|
+
self.info = self._client._call("GET", self._path)
|
|
404
|
+
return self
|
|
405
|
+
|
|
406
|
+
def send(self, prompt: str, interrupt: bool = False) -> "Agent":
|
|
407
|
+
"""Continues the conversation.
|
|
408
|
+
|
|
409
|
+
While a turn runs the message waits for it; with interrupt, the running
|
|
410
|
+
turn stops and queued messages are dropped first.
|
|
411
|
+
"""
|
|
412
|
+
body = {"prompt": prompt, "interrupt": True} if interrupt else {"prompt": prompt}
|
|
413
|
+
self.info = self._client._call("POST", self._path + "/messages", body)
|
|
414
|
+
return self
|
|
415
|
+
|
|
416
|
+
def interrupt(self) -> "Agent":
|
|
417
|
+
"""Stops the running turn and drops queued messages."""
|
|
418
|
+
self.info = self._client._call("POST", self._path + "/interrupt")
|
|
419
|
+
return self
|
|
420
|
+
|
|
421
|
+
def events(self, after: int = 0, follow: bool = True) -> Iterator[Dict[str, Any]]:
|
|
422
|
+
"""The agent's events (dicts) from after (default 0, the start).
|
|
423
|
+
|
|
424
|
+
With follow (the default), waits for new ones until the agent is idle;
|
|
425
|
+
without, stops at the newest. Each request waits up to 25 seconds, so a
|
|
426
|
+
client timeout must be longer than that.
|
|
427
|
+
"""
|
|
428
|
+
wait = 25 if follow else 0
|
|
429
|
+
while True:
|
|
430
|
+
page = self._client._call("GET", f"{self._path}/events?after={after}&wait={wait}")
|
|
431
|
+
self.info = page["agent"]
|
|
432
|
+
yield from page["events"]
|
|
433
|
+
after = page["next"]
|
|
434
|
+
if after >= self.info["events"] and (not follow or self.info["state"] == "idle"):
|
|
435
|
+
return
|
|
436
|
+
|
|
437
|
+
def wait(self) -> "Agent":
|
|
438
|
+
"""Waits until the agent is idle; then last_message and error tell how its last turn went."""
|
|
439
|
+
for _ in self.events(after=self.info["events"]):
|
|
440
|
+
pass
|
|
441
|
+
return self
|
|
File without changes
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import tempfile
|
|
4
|
+
import threading
|
|
5
|
+
import unittest
|
|
6
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
7
|
+
from unittest import mock
|
|
8
|
+
|
|
9
|
+
from cloudex import Cloudex, CloudexError, ExecResult, ProviderKey, Settings
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def info(id, **extra):
|
|
13
|
+
base = {
|
|
14
|
+
"id": id, "name": None, "size": "standard-2", "state": "running", "idleMinutes": 30,
|
|
15
|
+
"createdAt": 1, "updatedAt": 1, "startedAt": 1, "stoppedAt": None,
|
|
16
|
+
"savedAt": None, "savedBytes": None, "error": None,
|
|
17
|
+
}
|
|
18
|
+
base.update(extra)
|
|
19
|
+
return base
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class FakeAPI(BaseHTTPRequestHandler):
|
|
23
|
+
"""Answers by "METHOD /path" from routes and records what it was sent."""
|
|
24
|
+
|
|
25
|
+
routes = {}
|
|
26
|
+
seen = []
|
|
27
|
+
|
|
28
|
+
def _handle(self):
|
|
29
|
+
length = int(self.headers.get("Content-Length") or 0)
|
|
30
|
+
body = json.loads(self.rfile.read(length)) if length else None
|
|
31
|
+
route = f"{self.command} {self.path}"
|
|
32
|
+
FakeAPI.seen.append({"route": route, "auth": self.headers.get("Authorization"), "body": body})
|
|
33
|
+
status, answer = FakeAPI.routes.get(route, (404, {"error": f"no route {route}"}))
|
|
34
|
+
data = json.dumps(answer).encode()
|
|
35
|
+
self.send_response(status)
|
|
36
|
+
self.send_header("Content-Type", "application/json")
|
|
37
|
+
self.send_header("Content-Length", str(len(data)))
|
|
38
|
+
self.end_headers()
|
|
39
|
+
self.wfile.write(data)
|
|
40
|
+
|
|
41
|
+
do_GET = do_POST = do_PUT = do_PATCH = do_DELETE = _handle
|
|
42
|
+
|
|
43
|
+
def log_message(self, *args):
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class CloudexTest(unittest.TestCase):
|
|
48
|
+
@classmethod
|
|
49
|
+
def setUpClass(cls):
|
|
50
|
+
cls.server = ThreadingHTTPServer(("127.0.0.1", 0), FakeAPI)
|
|
51
|
+
threading.Thread(target=cls.server.serve_forever, daemon=True).start()
|
|
52
|
+
cls.url = f"http://127.0.0.1:{cls.server.server_address[1]}"
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def tearDownClass(cls):
|
|
56
|
+
cls.server.shutdown()
|
|
57
|
+
|
|
58
|
+
def setUp(self):
|
|
59
|
+
FakeAPI.routes = {}
|
|
60
|
+
FakeAPI.seen = []
|
|
61
|
+
env = {k: v for k, v in os.environ.items() if not k.startswith("CLOUDEX_")}
|
|
62
|
+
patcher = mock.patch.dict(os.environ, env, clear=True)
|
|
63
|
+
patcher.start()
|
|
64
|
+
self.addCleanup(patcher.stop)
|
|
65
|
+
self.cx = Cloudex(self.url + "/", "k1")
|
|
66
|
+
|
|
67
|
+
def test_create_sends_options_with_the_key(self):
|
|
68
|
+
FakeAPI.routes["POST /v1/sandboxes"] = (201, info("cx-aaa111", name="web"))
|
|
69
|
+
box = self.cx.create(name="web", idle_minutes=5)
|
|
70
|
+
self.assertEqual((box.id, box.name, box.state), ("cx-aaa111", "web", "running"))
|
|
71
|
+
self.assertEqual(
|
|
72
|
+
FakeAPI.seen, [{"route": "POST /v1/sandboxes", "auth": "Bearer k1", "body": {"name": "web", "idleMinutes": 5}}]
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def test_api_errors_raise_with_status_and_message(self):
|
|
76
|
+
FakeAPI.routes["GET /v1/sandboxes/nope"] = (404, {"error": "no sandbox named nope"})
|
|
77
|
+
with self.assertRaises(CloudexError) as caught:
|
|
78
|
+
self.cx.get("nope")
|
|
79
|
+
self.assertEqual((caught.exception.status, str(caught.exception)), (404, "no sandbox named nope"))
|
|
80
|
+
|
|
81
|
+
def test_lifecycle_updates_the_fields(self):
|
|
82
|
+
FakeAPI.routes["GET /v1/sandboxes/my%20box"] = (200, info("cx-aaa111", name="my box"))
|
|
83
|
+
FakeAPI.routes["POST /v1/sandboxes/cx-aaa111/stop"] = (200, info("cx-aaa111", state="stopped", savedAt=9))
|
|
84
|
+
FakeAPI.routes["POST /v1/sandboxes/cx-aaa111/resume"] = (200, info("cx-aaa111", notice="restored"))
|
|
85
|
+
FakeAPI.routes["DELETE /v1/sandboxes/cx-aaa111"] = (200, {"deleted": "cx-aaa111"})
|
|
86
|
+
box = self.cx.get("my box")
|
|
87
|
+
self.assertEqual(box.stop().state, "stopped")
|
|
88
|
+
self.assertEqual(box.info["savedAt"], 9)
|
|
89
|
+
box.resume(from_backup=True)
|
|
90
|
+
self.assertEqual(box.info["notice"], "restored")
|
|
91
|
+
box.delete()
|
|
92
|
+
self.assertEqual(FakeAPI.seen[-2]["body"], {"fromBackup": True})
|
|
93
|
+
self.assertEqual(FakeAPI.seen[-1]["route"], "DELETE /v1/sandboxes/cx-aaa111")
|
|
94
|
+
|
|
95
|
+
def test_exec_quotes_a_list(self):
|
|
96
|
+
FakeAPI.routes["GET /v1/sandboxes/web"] = (200, info("cx-aaa111"))
|
|
97
|
+
FakeAPI.routes["POST /v1/sandboxes/cx-aaa111/exec"] = (
|
|
98
|
+
200, {"exitCode": 1, "stdout": "", "stderr": "no", "timedOut": False, "ms": 3},
|
|
99
|
+
)
|
|
100
|
+
box = self.cx.get("web")
|
|
101
|
+
result = box.exec(["echo", "it's", "a b"], root=True, timeout_sec=5)
|
|
102
|
+
self.assertEqual(result, ExecResult(exit_code=1, stdout="", stderr="no", timed_out=False, ms=3))
|
|
103
|
+
self.assertEqual(FakeAPI.seen[-1]["body"], {"cmd": "echo 'it'\"'\"'s' 'a b'", "root": True, "timeoutSec": 5})
|
|
104
|
+
box.exec("ls | wc -l")
|
|
105
|
+
self.assertEqual(FakeAPI.seen[-1]["body"], {"cmd": "ls | wc -l"})
|
|
106
|
+
|
|
107
|
+
def test_fork_returns_working_sandboxes(self):
|
|
108
|
+
FakeAPI.routes["GET /v1/sandboxes/web"] = (200, info("cx-aaa111"))
|
|
109
|
+
FakeAPI.routes["POST /v1/sandboxes/cx-aaa111/fork"] = (201, [
|
|
110
|
+
info("cx-fff111", name="a-1", forkedFrom="cx-aaa111"),
|
|
111
|
+
info("cx-fff222", name="a-2", state="error", error="no room"),
|
|
112
|
+
])
|
|
113
|
+
FakeAPI.routes["POST /v1/sandboxes/cx-fff111/stop"] = (200, info("cx-fff111", state="stopped"))
|
|
114
|
+
forks = self.cx.get("web").fork(count=2, name="a")
|
|
115
|
+
self.assertEqual(FakeAPI.seen[-1]["body"], {"name": "a", "count": 2})
|
|
116
|
+
self.assertEqual([(f.name, f.state, f.forked_from) for f in forks], [("a-1", "running", "cx-aaa111"), ("a-2", "error", None)])
|
|
117
|
+
self.assertEqual(forks[0].stop().state, "stopped")
|
|
118
|
+
|
|
119
|
+
def test_desktop_links_to_the_viewer(self):
|
|
120
|
+
FakeAPI.routes["GET /v1/sandboxes/web"] = (200, info("cx-aaa111"))
|
|
121
|
+
FakeAPI.routes["POST /v1/sandboxes/cx-aaa111/desktop"] = (
|
|
122
|
+
200, {"port": 6080, "path": "vnc.html?autoconnect=1", "size": "1280x720", "installed": False, "started": True},
|
|
123
|
+
)
|
|
124
|
+
FakeAPI.routes["POST /v1/sandboxes/cx-aaa111/hosts"] = (200, {"url": "https://6080-cx-aaa111-tok.example.com"})
|
|
125
|
+
d = self.cx.get("web").desktop()
|
|
126
|
+
self.assertEqual(d.url, "https://6080-cx-aaa111-tok.example.com/vnc.html?autoconnect=1")
|
|
127
|
+
self.assertEqual(FakeAPI.seen[-2]["body"], {})
|
|
128
|
+
|
|
129
|
+
def test_environment_then_saved_deployment(self):
|
|
130
|
+
FakeAPI.routes["GET /v1/health"] = (200, {"ok": True, "version": "v0", "previewZone": None})
|
|
131
|
+
os.environ["CLOUDEX_API_URL"] = self.url
|
|
132
|
+
os.environ["CLOUDEX_API_KEY"] = "from-env"
|
|
133
|
+
Cloudex().health()
|
|
134
|
+
self.assertEqual(FakeAPI.seen[-1]["auth"], "Bearer from-env")
|
|
135
|
+
del os.environ["CLOUDEX_API_URL"], os.environ["CLOUDEX_API_KEY"]
|
|
136
|
+
with tempfile.TemporaryDirectory() as d:
|
|
137
|
+
with open(os.path.join(d, "config.json"), "w") as f:
|
|
138
|
+
json.dump({"apiUrl": self.url, "apiKey": "from-config"}, f)
|
|
139
|
+
os.environ["CLOUDEX_CONFIG_DIR"] = d
|
|
140
|
+
self.assertEqual(Cloudex().health().version, "v0")
|
|
141
|
+
self.assertEqual(FakeAPI.seen[-1]["auth"], "Bearer from-config")
|
|
142
|
+
|
|
143
|
+
def test_no_deployment_is_a_clear_error(self):
|
|
144
|
+
with tempfile.TemporaryDirectory() as d:
|
|
145
|
+
os.environ["CLOUDEX_CONFIG_DIR"] = d
|
|
146
|
+
with self.assertRaises(CloudexError) as caught:
|
|
147
|
+
Cloudex()
|
|
148
|
+
self.assertEqual(caught.exception.status, 0)
|
|
149
|
+
self.assertIn("cloudex login", str(caught.exception))
|
|
150
|
+
|
|
151
|
+
def test_provider_keys(self):
|
|
152
|
+
FakeAPI.routes["PUT /v1/keys/anthropic"] = (200, {"provider": "anthropic", "hint": "…wxyz", "updatedAt": 5})
|
|
153
|
+
FakeAPI.routes["GET /v1/keys"] = (200, [{"provider": "anthropic", "hint": "…wxyz", "updatedAt": 5}])
|
|
154
|
+
FakeAPI.routes["DELETE /v1/keys/anthropic"] = (200, {"deleted": "anthropic"})
|
|
155
|
+
FakeAPI.routes["GET /v1/sandboxes/web"] = (200, info("cx-aaa111", providerKeys=["anthropic"]))
|
|
156
|
+
self.assertEqual(self.cx.set_key("anthropic", "sk-ant-x-wxyz"), ProviderKey("anthropic", "…wxyz", 5))
|
|
157
|
+
self.assertEqual(FakeAPI.seen[-1]["body"], {"key": "sk-ant-x-wxyz"})
|
|
158
|
+
self.assertEqual([k.provider for k in self.cx.list_keys()], ["anthropic"])
|
|
159
|
+
self.assertEqual(self.cx.get("web").provider_keys, ["anthropic"])
|
|
160
|
+
self.cx.delete_key("anthropic")
|
|
161
|
+
self.assertEqual(FakeAPI.seen[-1]["route"], "DELETE /v1/keys/anthropic")
|
|
162
|
+
|
|
163
|
+
def test_agents(self):
|
|
164
|
+
def agent(**extra):
|
|
165
|
+
base = {"id": "ag-aaaa1111", "agent": "claude", "state": "running", "cwd": "/home/cloudex", "model": None,
|
|
166
|
+
"turns": 1, "queued": 0, "events": 1, "sessionId": None, "lastMessage": None,
|
|
167
|
+
"lastExitCode": None, "error": None, "createdAt": 1, "updatedAt": 1}
|
|
168
|
+
base.update(extra)
|
|
169
|
+
return base
|
|
170
|
+
|
|
171
|
+
path = "/v1/sandboxes/cx-aaa111/agents/ag-aaaa1111"
|
|
172
|
+
FakeAPI.routes["GET /v1/sandboxes/web"] = (200, info("cx-aaa111"))
|
|
173
|
+
FakeAPI.routes["POST /v1/sandboxes/cx-aaa111/agents"] = (201, agent(turns=0, events=0))
|
|
174
|
+
FakeAPI.routes[f"GET {path}/events?after=0&wait=25"] = (200, {
|
|
175
|
+
"events": [{"seq": 1, "turn": 1, "at": 1, "type": "prompt", "text": "fix it"}],
|
|
176
|
+
"next": 1, "agent": agent(events=2)})
|
|
177
|
+
FakeAPI.routes[f"GET {path}/events?after=1&wait=25"] = (200, {
|
|
178
|
+
"events": [{"seq": 2, "turn": 1, "at": 2, "type": "end", "exitCode": 0}],
|
|
179
|
+
"next": 2, "agent": agent(state="idle", events=2, lastMessage="fixed")})
|
|
180
|
+
FakeAPI.routes[f"GET {path}/events?after=2&wait=25"] = (200, {"events": [], "next": 2, "agent": agent(state="idle", events=2, lastMessage="fixed")})
|
|
181
|
+
FakeAPI.routes[f"POST {path}/messages"] = (200, agent(queued=1))
|
|
182
|
+
FakeAPI.routes[f"POST {path}/interrupt"] = (200, agent(state="idle", error="interrupted"))
|
|
183
|
+
|
|
184
|
+
a = self.cx.get("web").prompt("fix it", agent="codex")
|
|
185
|
+
self.assertEqual(FakeAPI.seen[-1]["body"], {"prompt": "fix it", "agent": "codex"})
|
|
186
|
+
self.assertEqual([e["type"] for e in a.events()], ["prompt", "end"])
|
|
187
|
+
self.assertEqual((a.state, a.last_message), ("idle", "fixed"))
|
|
188
|
+
self.assertIs(a.wait(), a)
|
|
189
|
+
self.assertEqual(FakeAPI.seen[-1]["route"], f"GET {path}/events?after=2&wait=25")
|
|
190
|
+
a.send("and the docs", interrupt=True)
|
|
191
|
+
self.assertEqual(FakeAPI.seen[-1]["body"], {"prompt": "and the docs", "interrupt": True})
|
|
192
|
+
self.assertEqual(a.interrupt().error, "interrupted")
|
|
193
|
+
|
|
194
|
+
def test_settings(self):
|
|
195
|
+
view = {"env": ["A"], "files": ["~/.npmrc"], "repo": None}
|
|
196
|
+
FakeAPI.routes["POST /v1/sandboxes"] = (201, info("cx-aaa111"))
|
|
197
|
+
FakeAPI.routes["GET /v1/sandboxes/cx-aaa111/settings"] = (200, view)
|
|
198
|
+
FakeAPI.routes["PATCH /v1/sandboxes/cx-aaa111/settings"] = (200, dict(view, env=[]))
|
|
199
|
+
box = self.cx.create(env={"A": "1"}, files={"~/.npmrc": "x"}, repo="https://github.com/you/app#dev")
|
|
200
|
+
self.assertEqual(FakeAPI.seen[-1]["body"], {"env": {"A": "1"}, "files": {"~/.npmrc": "x"}, "repo": "https://github.com/you/app#dev"})
|
|
201
|
+
self.assertEqual(box.settings(), Settings(env=["A"], files=["~/.npmrc"], repo=None))
|
|
202
|
+
self.assertEqual(box.update_settings(env={"A": None}).env, [])
|
|
203
|
+
self.assertEqual(FakeAPI.seen[-1]["body"], {"env": {"A": None}})
|
|
204
|
+
|
|
205
|
+
def test_unreachable_api(self):
|
|
206
|
+
with self.assertRaises(CloudexError) as caught:
|
|
207
|
+
Cloudex("http://127.0.0.1:9", "k").list()
|
|
208
|
+
self.assertEqual(caught.exception.status, 0)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
if __name__ == "__main__":
|
|
212
|
+
unittest.main()
|