anona-compute 0.2.0__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.
@@ -0,0 +1,9 @@
1
+ node_modules/
2
+ dist/
3
+ *.tgz
4
+ __pycache__/
5
+ *.pyc
6
+ *.egg-info/
7
+ .venv/
8
+ .pytest_cache/
9
+ build/
@@ -0,0 +1,66 @@
1
+ Metadata-Version: 2.5
2
+ Name: anona-compute
3
+ Version: 0.2.0
4
+ Summary: Anona Compute extras on top of the E2B SDK
5
+ Project-URL: Homepage, https://compute.anonalabs.com
6
+ Project-URL: Documentation, https://compute.anonalabs.com/docs
7
+ Project-URL: Repository, https://github.com/anonalabs/anona-compute-sdk
8
+ License-Expression: Apache-2.0
9
+ Requires-Python: >=3.11
10
+ Requires-Dist: e2b<3,>=2.61
11
+ Provides-Extra: desktop
12
+ Requires-Dist: e2b-desktop<3,>=2.7.2; extra == 'desktop'
13
+ Provides-Extra: test
14
+ Requires-Dist: pytest==9.1.1; extra == 'test'
15
+ Description-Content-Type: text/markdown
16
+
17
+ # anona-compute (Python)
18
+
19
+ Optional extras for [Anona Compute](https://compute.anonalabs.com) on top of the stock E2B SDK.
20
+ The stock SDK already works on its own:
21
+
22
+ ```sh
23
+ export E2B_DOMAIN=compute.anonalabs.com
24
+ export E2B_API_KEY=ac_... # an API key from https://compute.anonalabs.com
25
+ ```
26
+
27
+ This package adds sandbox sizes, live view tokens and SSH certificates.
28
+
29
+ ```sh
30
+ pip install anona-compute
31
+ ```
32
+
33
+ ```python
34
+ import anona_compute
35
+
36
+ anona_compute.configure(api_key="ac_...") # or set ANONA_API_KEY / E2B_API_KEY
37
+ sbx = anona_compute.Sandbox.create("python", size="large") # a subclass of e2b.Sandbox
38
+
39
+ url = sbx.live.url(mode="dom") # view-only embed URL, 600 s
40
+ tok = sbx.live.token(scope="control", channels=["pty"], ttl=120)
41
+ sbx.live.revoke_all()
42
+
43
+ cert = sbx.ssh.certificate("~/.ssh/anona_ed25519.pub")
44
+ cert.write("~/.ssh/anona_ed25519-cert.pub")
45
+ print(sbx.ssh.config(identity_file="~/.ssh/anona_ed25519"))
46
+ ```
47
+
48
+ Environment mapping: `ANONA_API_KEY` → `E2B_API_KEY`, `ANONA_DOMAIN` → `E2B_DOMAIN`
49
+ (default `compute.anonalabs.com`), `ANONA_API_URL` → `E2B_API_URL` (deprecated by E2B, fallback only).
50
+ `E2B_*` values always win.
51
+
52
+ `size` is `small` (1 vCPU, 2 GiB, 10 GB disk), `medium` (2, 4 GiB, 20 GB), `large` (4, 8 GiB, 40 GB) or `xl` (8, 16 GiB, 80 GB). Without it a sandbox runs at its template's own size.
53
+
54
+ Install the desktop extra with `pip install "anona-compute[desktop]"`.
55
+
56
+ This is the Python library only. The `anona-compute` command-line tool is a separate npm package: `npm install -g @anona-labs/anona-compute`.
57
+
58
+ ## Development
59
+
60
+ ```sh
61
+ uv venv -p 3.12 .venv && uv pip install -p .venv/bin/python -e ".[test]"
62
+ .venv/bin/python -m pytest -q # fake gateway, no network
63
+ GATEWAY_URL=... GATEWAY_API_KEY=... .venv/bin/python -m pytest -m gateway
64
+ ```
65
+
66
+ The version comes from the git tag `sdk-v<version>` (hatch-vcs); untagged builds get a `0.0.1.devN+g<sha>` development version.
@@ -0,0 +1,50 @@
1
+ # anona-compute (Python)
2
+
3
+ Optional extras for [Anona Compute](https://compute.anonalabs.com) on top of the stock E2B SDK.
4
+ The stock SDK already works on its own:
5
+
6
+ ```sh
7
+ export E2B_DOMAIN=compute.anonalabs.com
8
+ export E2B_API_KEY=ac_... # an API key from https://compute.anonalabs.com
9
+ ```
10
+
11
+ This package adds sandbox sizes, live view tokens and SSH certificates.
12
+
13
+ ```sh
14
+ pip install anona-compute
15
+ ```
16
+
17
+ ```python
18
+ import anona_compute
19
+
20
+ anona_compute.configure(api_key="ac_...") # or set ANONA_API_KEY / E2B_API_KEY
21
+ sbx = anona_compute.Sandbox.create("python", size="large") # a subclass of e2b.Sandbox
22
+
23
+ url = sbx.live.url(mode="dom") # view-only embed URL, 600 s
24
+ tok = sbx.live.token(scope="control", channels=["pty"], ttl=120)
25
+ sbx.live.revoke_all()
26
+
27
+ cert = sbx.ssh.certificate("~/.ssh/anona_ed25519.pub")
28
+ cert.write("~/.ssh/anona_ed25519-cert.pub")
29
+ print(sbx.ssh.config(identity_file="~/.ssh/anona_ed25519"))
30
+ ```
31
+
32
+ Environment mapping: `ANONA_API_KEY` → `E2B_API_KEY`, `ANONA_DOMAIN` → `E2B_DOMAIN`
33
+ (default `compute.anonalabs.com`), `ANONA_API_URL` → `E2B_API_URL` (deprecated by E2B, fallback only).
34
+ `E2B_*` values always win.
35
+
36
+ `size` is `small` (1 vCPU, 2 GiB, 10 GB disk), `medium` (2, 4 GiB, 20 GB), `large` (4, 8 GiB, 40 GB) or `xl` (8, 16 GiB, 80 GB). Without it a sandbox runs at its template's own size.
37
+
38
+ Install the desktop extra with `pip install "anona-compute[desktop]"`.
39
+
40
+ This is the Python library only. The `anona-compute` command-line tool is a separate npm package: `npm install -g @anona-labs/anona-compute`.
41
+
42
+ ## Development
43
+
44
+ ```sh
45
+ uv venv -p 3.12 .venv && uv pip install -p .venv/bin/python -e ".[test]"
46
+ .venv/bin/python -m pytest -q # fake gateway, no network
47
+ GATEWAY_URL=... GATEWAY_API_KEY=... .venv/bin/python -m pytest -m gateway
48
+ ```
49
+
50
+ The version comes from the git tag `sdk-v<version>` (hatch-vcs); untagged builds get a `0.0.1.devN+g<sha>` development version.
@@ -0,0 +1,55 @@
1
+ """Anona Compute extras on top of the E2B SDK.
2
+
3
+ The stock E2B SDK works against Anona with ``E2B_DOMAIN=compute.anonalabs.com``
4
+ and ``E2B_API_KEY``; this package is optional. It adds:
5
+
6
+ - ``anona_compute.configure(api_key=...)`` and ``anona_compute.Sandbox``, a subclass of
7
+ ``e2b.Sandbox`` that defaults to the Anona domain.
8
+ - ``sandbox.live``: viewer tokens and embed URLs for the live view.
9
+ - ``sandbox.ssh``: short-lived SSH certificates and an ``ssh_config`` snippet.
10
+ - ``sandbox.idle_pause``: pause the sandbox once nothing has used it for a while.
11
+
12
+ Environment mapping (only variables the E2B SDK 2.61.0 reads in
13
+ ``e2b/connection_config.py`` are targeted):
14
+
15
+ ================== ============== ==========================================
16
+ Anona variable E2B variable Notes
17
+ ================== ============== ==========================================
18
+ ``ANONA_API_KEY`` ``E2B_API_KEY`` used when ``E2B_API_KEY`` is unset
19
+ ``ANONA_DOMAIN`` ``E2B_DOMAIN`` default ``compute.anonalabs.com``
20
+ ``ANONA_API_URL`` ``E2B_API_URL`` deprecated by E2B; fallback only
21
+ ================== ============== ==========================================
22
+
23
+ ``E2B_*`` values always win over ``ANONA_*`` values.
24
+ """
25
+
26
+ from importlib.metadata import PackageNotFoundError, version as _version
27
+
28
+ from anona_compute._config import DEFAULT_API_URL, DEFAULT_DOMAIN, ENV_MAP, configure
29
+ from anona_compute.idle import MAX_IDLE_PAUSE_MINUTES, IdlePause
30
+ from anona_compute.live import Live, ViewerToken
31
+ from anona_compute.sandbox import SIZES, Sandbox, SandboxSize, size_metadata
32
+ from anona_compute.ssh import Ssh, SshCertificate
33
+
34
+ try:
35
+ __version__ = _version("anona-compute")
36
+ except PackageNotFoundError: # running from a source checkout
37
+ __version__ = "0.0.0"
38
+
39
+ __all__ = [
40
+ "DEFAULT_API_URL",
41
+ "DEFAULT_DOMAIN",
42
+ "ENV_MAP",
43
+ "MAX_IDLE_PAUSE_MINUTES",
44
+ "IdlePause",
45
+ "Live",
46
+ "SIZES",
47
+ "Sandbox",
48
+ "SandboxSize",
49
+ "Ssh",
50
+ "SshCertificate",
51
+ "ViewerToken",
52
+ "size_metadata",
53
+ "__version__",
54
+ "configure",
55
+ ]
@@ -0,0 +1,82 @@
1
+ """Environment mapping between Anona settings and the variables the E2B SDK reads.
2
+
3
+ The E2B Python SDK 2.61.0 reads these variables in ``e2b/connection_config.py``:
4
+ ``E2B_API_KEY``, ``E2B_DOMAIN``, ``E2B_API_URL`` (deprecated), ``E2B_SANDBOX_URL``
5
+ (deprecated), ``E2B_DEBUG`` and ``E2B_HTTP_VERSION``. Only the first four that
6
+ matter for pointing the SDK at Anona are mapped here; no new E2B variables are
7
+ invented.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ from typing import Mapping, MutableMapping, Optional
14
+
15
+ DEFAULT_DOMAIN = "compute.anonalabs.com"
16
+ """Domain customers set as ``E2B_DOMAIN``. The SDK derives ``https://api.<domain>``."""
17
+
18
+ DEFAULT_API_URL = f"https://api.{DEFAULT_DOMAIN}"
19
+ """Public API endpoint. Only used directly by the Anona extras."""
20
+
21
+ # Anona variable -> E2B variable. ANONA_* wins only when the E2B variable is unset,
22
+ # so a customer who already exported E2B_* keeps full control.
23
+ ENV_MAP: Mapping[str, str] = {
24
+ "ANONA_API_KEY": "E2B_API_KEY",
25
+ "ANONA_DOMAIN": "E2B_DOMAIN",
26
+ "ANONA_API_URL": "E2B_API_URL",
27
+ }
28
+
29
+
30
+ COMPUTE_KEY_PREFIXES = ("ac_", "e2b_")
31
+ """Prefixes of an Anona Compute API key (``ac_``, legacy ``e2b_``)."""
32
+
33
+
34
+ def compute_key(value: Optional[str]) -> Optional[str]:
35
+ """``value`` if it is an Anona Compute key, else ``None``.
36
+
37
+ ``ANONA_API_KEY`` is shared with Anona Memory, whose key must never be
38
+ sent to the compute API. The ``anona-compute`` CLI applies the same rule.
39
+ """
40
+ if value and value.startswith(COMPUTE_KEY_PREFIXES):
41
+ return value
42
+ return None
43
+
44
+
45
+ def apply_env(environ: Optional[MutableMapping[str, str]] = None) -> None:
46
+ """Copy ``ANONA_*`` values to the ``E2B_*`` names and default ``E2B_DOMAIN``.
47
+
48
+ ``ANONA_API_KEY`` is copied only when it holds a compute key.
49
+ """
50
+ env = os.environ if environ is None else environ
51
+ for src, dst in ENV_MAP.items():
52
+ value = env.get(src)
53
+ if src == "ANONA_API_KEY":
54
+ value = compute_key(value)
55
+ if value and not env.get(dst):
56
+ env[dst] = value
57
+ if not env.get("E2B_DOMAIN"):
58
+ env["E2B_DOMAIN"] = DEFAULT_DOMAIN
59
+
60
+
61
+ def configure(
62
+ api_key: Optional[str] = None,
63
+ *,
64
+ domain: Optional[str] = None,
65
+ api_url: Optional[str] = None,
66
+ debug: Optional[bool] = None,
67
+ ) -> None:
68
+ """Point the stock E2B SDK (and the Anona extras) at Anona Compute.
69
+
70
+ Explicit arguments overwrite the matching ``E2B_*`` variables in this
71
+ process. Missing values fall back to ``ANONA_*`` variables, then to the
72
+ defaults (``E2B_DOMAIN=compute.anonalabs.com``).
73
+ """
74
+ if api_key is not None:
75
+ os.environ["E2B_API_KEY"] = api_key
76
+ if domain is not None:
77
+ os.environ["E2B_DOMAIN"] = domain
78
+ if api_url is not None:
79
+ os.environ["E2B_API_URL"] = api_url
80
+ if debug is not None:
81
+ os.environ["E2B_DEBUG"] = "true" if debug else "false"
82
+ apply_env()
@@ -0,0 +1,78 @@
1
+ """Small HTTP helper for the Anona-only gateway endpoints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, Optional
6
+
7
+ import httpx
8
+ from e2b.connection_config import ConnectionConfig
9
+ from e2b.exceptions import (
10
+ AuthenticationException,
11
+ InvalidArgumentException,
12
+ NotFoundException,
13
+ RateLimitException,
14
+ SandboxException,
15
+ )
16
+
17
+
18
+ def _error(resp: httpx.Response) -> Exception:
19
+ try:
20
+ body = resp.json()
21
+ message = body.get("message") or resp.text
22
+ except ValueError:
23
+ message = resp.text
24
+ message = f"{resp.status_code}: {message}"
25
+ if resp.status_code == 400:
26
+ return InvalidArgumentException(message)
27
+ if resp.status_code in (401, 403):
28
+ return AuthenticationException(message)
29
+ if resp.status_code == 404:
30
+ return NotFoundException(message)
31
+ if resp.status_code == 429:
32
+ return RateLimitException(message)
33
+ return SandboxException(message)
34
+
35
+
36
+ def _request(
37
+ config: ConnectionConfig,
38
+ method: str,
39
+ path: str,
40
+ body: Optional[Dict[str, Any]] = None,
41
+ ) -> Optional[Dict[str, Any]]:
42
+ headers = dict(config.headers)
43
+ if config.api_key:
44
+ headers["X-API-Key"] = config.api_key
45
+ with httpx.Client(
46
+ base_url=config.api_url,
47
+ timeout=config.request_timeout,
48
+ proxy=config.proxy, # type: ignore[arg-type]
49
+ ) as client:
50
+ resp = client.request(method, path, json=body, headers=headers)
51
+ if resp.status_code >= 400:
52
+ raise _error(resp)
53
+ if resp.status_code == 204 or not resp.content:
54
+ return None
55
+ return resp.json()
56
+
57
+
58
+ def post_json(
59
+ config: ConnectionConfig,
60
+ path: str,
61
+ body: Optional[Dict[str, Any]] = None,
62
+ ) -> Optional[Dict[str, Any]]:
63
+ """POST to ``<api_url><path>`` with the sandbox's API key; return the JSON body."""
64
+ return _request(config, "POST", path, body)
65
+
66
+
67
+ def put_json(
68
+ config: ConnectionConfig,
69
+ path: str,
70
+ body: Optional[Dict[str, Any]] = None,
71
+ ) -> Optional[Dict[str, Any]]:
72
+ """PUT to ``<api_url><path>`` with the sandbox's API key; return the JSON body."""
73
+ return _request(config, "PUT", path, body)
74
+
75
+
76
+ def get_json(config: ConnectionConfig, path: str) -> Optional[Dict[str, Any]]:
77
+ """GET ``<api_url><path>`` with the sandbox's API key; return the JSON body."""
78
+ return _request(config, "GET", path)
@@ -0,0 +1,56 @@
1
+ """``anona_compute.desktop.Sandbox``: the stock ``e2b_desktop.Sandbox`` plus Anona extras.
2
+
3
+ Every e2b-desktop call (mouse, keyboard, screenshot, ``stream.start`` and the
4
+ noVNC ``stream.get_url``) is inherited unchanged. The additions are the same
5
+ ``live`` and ``ssh`` helpers as ``anona_compute.Sandbox`` and ``live_url()``, the
6
+ Anona live-view link for the desktop channel. That channel is served by the
7
+ template's guest broker (VNC on 127.0.0.1:5901, started on first use), so it
8
+ needs no ``stream.start()`` and never exposes port 6080.
9
+
10
+ Install with ``pip install "anona-compute[desktop]"``.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Unpack
16
+
17
+ try:
18
+ import e2b_desktop
19
+ except ImportError as e: # pragma: no cover - exercised only without the extra
20
+ raise ImportError('anona_compute.desktop needs e2b-desktop: pip install "anona-compute[desktop]"') from e
21
+
22
+ from e2b.connection_config import ApiParams
23
+
24
+ from anona_compute.idle import IdlePause
25
+ from anona_compute.live import MAX_TTL, Live
26
+ from anona_compute.sandbox import anona_defaults
27
+ from anona_compute.ssh import Ssh
28
+
29
+
30
+ class Sandbox(e2b_desktop.Sandbox):
31
+ """``e2b_desktop.Sandbox`` with ``live``, ``ssh``, ``idle_pause`` and ``live_url()``."""
32
+
33
+ @classmethod
34
+ def _resolve_api_params(cls, **opts: Unpack[ApiParams]) -> ApiParams:
35
+ merged = dict(anona_defaults())
36
+ resolved = super()._resolve_api_params(**opts)
37
+ merged.update({k: v for k, v in resolved.items() if v is not None})
38
+ return merged # type: ignore[return-value]
39
+
40
+ @property
41
+ def live(self) -> Live:
42
+ return Live(self.sandbox_id, self.connection_config)
43
+
44
+ @property
45
+ def ssh(self) -> Ssh:
46
+ return Ssh(self.sandbox_id, self.connection_config)
47
+
48
+ @property
49
+ def idle_pause(self) -> IdlePause:
50
+ """Idle pause: ``get()``, ``set(minutes)``, ``off()``, as on ``anona_compute.Sandbox``."""
51
+ return IdlePause(self.sandbox_id, self.connection_config)
52
+
53
+ def live_url(self, control: bool = True, ttl: int = MAX_TTL) -> str:
54
+ """Embed URL for the desktop channel (control scope by default: the
55
+ desktop channel carries RFB input, which a view token may not send)."""
56
+ return self.live.url(mode="desktop", scope="control" if control else "view", ttl=ttl)
@@ -0,0 +1,72 @@
1
+ """Idle pause: pause a sandbox once nothing has used it for a while.
2
+
3
+ Gateway contract (Anona routes, not E2B):
4
+
5
+ - ``PUT /v1/sandboxes/{id}/auto-pause`` with ``{"idleMinutes": n}`` returns the
6
+ sandbox's access settings. ``n`` is 1 to 10080 (one week); ``0`` turns idle
7
+ pausing off.
8
+ - ``GET /v1/sandboxes/{id}/access`` returns ``idlePauseMinutes`` among them.
9
+
10
+ This is **not** the same as E2B's ``autoPause``/``on_timeout``, which decides what
11
+ happens when the sandbox's *deadline* runs out. Idle pause has no deadline: the
12
+ sandbox keeps running for as long as it is used, and pauses once it is not.
13
+
14
+ What counts as use, so the clock restarts: any
15
+ request to any of the sandbox's ports except envd's ``GET /health``; an open live
16
+ view, terminal, SSH session or byte pipe; and any process envd started that is
17
+ still running. Not counted: CPU burned inside the guest by something envd did not
18
+ start, outbound traffic the sandbox makes itself, and API calls *about* the
19
+ sandbox. A sandbox busy on work it started by itself will still pause.
20
+
21
+ Resume with ``Sandbox.connect(sandbox_id)``.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from urllib.parse import quote
27
+ from typing import TYPE_CHECKING
28
+
29
+ from e2b.exceptions import InvalidArgumentException
30
+
31
+ from anona_compute._http import get_json, put_json
32
+
33
+ if TYPE_CHECKING:
34
+ from e2b.connection_config import ConnectionConfig
35
+
36
+ #: Longest idle time the gateway accepts (store.MaxIdlePauseMinutes): one week.
37
+ MAX_IDLE_PAUSE_MINUTES = 10080
38
+
39
+
40
+ class IdlePause:
41
+ """``sandbox.idle_pause``: read and set this sandbox's idle-pause time."""
42
+
43
+ def __init__(self, sandbox_id: str, config: "ConnectionConfig"):
44
+ self._sandbox_id = sandbox_id
45
+ self._config = config
46
+
47
+ @property
48
+ def _path(self) -> str:
49
+ return f"/v1/sandboxes/{quote(self._sandbox_id, safe='')}"
50
+
51
+ def get(self) -> int:
52
+ """Minutes of idleness before this sandbox pauses; ``0`` when off."""
53
+ body = get_json(self._config, f"{self._path}/access") or {}
54
+ return int(body.get("idlePauseMinutes") or 0)
55
+
56
+ def set(self, minutes: int) -> int:
57
+ """Pause this sandbox after ``minutes`` without use. ``0`` turns it off.
58
+
59
+ Returns the value the gateway recorded, which is what later reads will show.
60
+ """
61
+ if not isinstance(minutes, int) or isinstance(minutes, bool):
62
+ raise InvalidArgumentException("minutes must be an int")
63
+ if minutes < 0 or minutes > MAX_IDLE_PAUSE_MINUTES:
64
+ raise InvalidArgumentException(
65
+ f"minutes must be 0 (off) to {MAX_IDLE_PAUSE_MINUTES}"
66
+ )
67
+ body = put_json(self._config, f"{self._path}/auto-pause", {"idleMinutes": minutes}) or {}
68
+ return int(body.get("idlePauseMinutes") or 0)
69
+
70
+ def off(self) -> int:
71
+ """Stop pausing this sandbox when it is idle. Same as ``set(0)``."""
72
+ return self.set(0)
@@ -0,0 +1,106 @@
1
+ """Live view helpers: mint viewer tokens and embed URLs, revoke viewers.
2
+
3
+ Gateway contract (plan 05, section 3.4):
4
+
5
+ - ``POST /v1/sandboxes/{id}/viewer-tokens`` with
6
+ ``{"scope": "view"|"control", "channels": [...], "ttlSeconds": n}`` returns
7
+ ``{"token", "expiresAt", "embedUrl"}``.
8
+ - ``POST /v1/sandboxes/{id}/viewer-tokens:revoke-all`` returns 200 ``{"epoch": n}``.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import base64
14
+ import json
15
+ from dataclasses import dataclass, field
16
+ from datetime import datetime
17
+ from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional
18
+ from urllib.parse import quote
19
+
20
+ from e2b.exceptions import InvalidArgumentException
21
+
22
+ from anona_compute._http import post_json
23
+
24
+ if TYPE_CHECKING:
25
+ from e2b.connection_config import ConnectionConfig
26
+
27
+ SCOPES = ("view", "control")
28
+ CHANNELS = ("dom", "pty", "desktop", "thumb")
29
+ MIN_TTL = 30
30
+ MAX_TTL = 600
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class ViewerToken:
35
+ """A signed viewer token. ``repr`` never prints the token itself."""
36
+
37
+ token: str = field(repr=False)
38
+ expires_at: datetime
39
+ embed_url: str = field(repr=False)
40
+
41
+ def claims(self) -> Dict[str, Any]:
42
+ """Decode the JWS payload without checking the signature (for inspection only)."""
43
+ parts = self.token.split(".")
44
+ if len(parts) != 3:
45
+ raise ValueError("viewer token is not a compact JWS")
46
+ payload = parts[1] + "=" * (-len(parts[1]) % 4)
47
+ return json.loads(base64.urlsafe_b64decode(payload))
48
+
49
+
50
+ def _parse_time(value: str) -> datetime:
51
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
52
+
53
+
54
+ def _validate(scope: str, channels: List[str], ttl: int) -> None:
55
+ if scope not in SCOPES:
56
+ raise InvalidArgumentException(f"scope must be one of {SCOPES}, got {scope!r}")
57
+ if not channels:
58
+ raise InvalidArgumentException("channels must not be empty")
59
+ unknown = [c for c in channels if c not in CHANNELS]
60
+ if unknown:
61
+ raise InvalidArgumentException(f"unknown channels {unknown}; allowed {CHANNELS}")
62
+ # The gateway decodes ttlSeconds into an int and answers 400 to 60.5, so a
63
+ # fraction is refused here, as the TypeScript SDK does (Number.isInteger).
64
+ if isinstance(ttl, bool) or not isinstance(ttl, int) or not MIN_TTL <= ttl <= MAX_TTL:
65
+ raise InvalidArgumentException(f"ttl must be a whole number of seconds from {MIN_TTL} to {MAX_TTL}")
66
+
67
+
68
+ class Live:
69
+ """``sandbox.live``: viewer tokens for the live view of one sandbox."""
70
+
71
+ def __init__(self, sandbox_id: str, config: "ConnectionConfig"):
72
+ self._sandbox_id = sandbox_id
73
+ self._config = config
74
+
75
+ def _path(self, suffix: str = "") -> str:
76
+ return f"/v1/sandboxes/{quote(self._sandbox_id, safe='')}/viewer-tokens{suffix}"
77
+
78
+ def token(
79
+ self,
80
+ scope: str = "view",
81
+ channels: Optional[Iterable[str]] = None,
82
+ ttl: int = MAX_TTL,
83
+ ) -> ViewerToken:
84
+ """Mint a viewer token (default: view-only DOM channel, 600 s)."""
85
+ chans = list(channels) if channels is not None else ["dom"]
86
+ _validate(scope, chans, ttl)
87
+ body = post_json(
88
+ self._config,
89
+ self._path(),
90
+ {"scope": scope, "channels": chans, "ttlSeconds": ttl},
91
+ )
92
+ if not body:
93
+ raise InvalidArgumentException("gateway returned an empty viewer token response")
94
+ return ViewerToken(
95
+ token=body["token"],
96
+ expires_at=_parse_time(body["expiresAt"]),
97
+ embed_url=body["embedUrl"],
98
+ )
99
+
100
+ def url(self, mode: str = "dom", scope: str = "view", ttl: int = MAX_TTL) -> str:
101
+ """Mint a token for one channel and return its embed URL."""
102
+ return self.token(scope=scope, channels=[mode], ttl=ttl).embed_url
103
+
104
+ def revoke_all(self) -> None:
105
+ """Close every open viewer of this sandbox and invalidate issued tokens."""
106
+ post_json(self._config, self._path(":revoke-all"))
@@ -0,0 +1,94 @@
1
+ """``anona_compute.Sandbox``: the stock ``e2b.Sandbox`` plus Anona defaults and extras."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import Any, Literal, Unpack
7
+
8
+ import e2b
9
+ from e2b.connection_config import ApiParams
10
+
11
+ from anona_compute._config import DEFAULT_DOMAIN, compute_key
12
+ from anona_compute.idle import IdlePause
13
+ from anona_compute.live import Live
14
+ from anona_compute.ssh import Ssh
15
+
16
+
17
+ SandboxSize = Literal["small", "medium", "large", "xl"]
18
+ SIZES: tuple[str, ...] = ("small", "medium", "large", "xl")
19
+ _SIZE_ALIASES = {"s": "small", "m": "medium", "l": "large", "xlarge": "xl", "x-large": "xl"}
20
+
21
+ # The E2B SDK cannot send the create body's ``size`` (an Anona extension), so
22
+ # the size rides in this metadata key; the gateway consumes it and never
23
+ # stores it as metadata.
24
+ SIZE_METADATA_KEY = "anona.size"
25
+
26
+
27
+ def size_metadata(size: str, metadata: dict[str, str] | None = None) -> dict[str, str]:
28
+ """Return ``metadata`` with the sandbox size set (small, medium, large or xl)."""
29
+ name = _SIZE_ALIASES.get(size.strip().lower(), size.strip().lower())
30
+ if name not in SIZES:
31
+ raise ValueError(f"unknown sandbox size {size!r}: use one of {', '.join(SIZES)}")
32
+ out = dict(metadata or {})
33
+ out[SIZE_METADATA_KEY] = name
34
+ return out
35
+
36
+
37
+ def anona_defaults() -> ApiParams:
38
+ """API params that point the SDK at Anona unless E2B_* variables say otherwise."""
39
+ params: ApiParams = {}
40
+ if not os.getenv("E2B_DOMAIN"):
41
+ params["domain"] = os.getenv("ANONA_DOMAIN") or DEFAULT_DOMAIN
42
+ key = compute_key(os.getenv("ANONA_API_KEY"))
43
+ if not os.getenv("E2B_API_KEY") and key:
44
+ params["api_key"] = key
45
+ if not os.getenv("E2B_API_URL") and os.getenv("ANONA_API_URL"):
46
+ params["api_url"] = os.environ["ANONA_API_URL"]
47
+ return params
48
+
49
+
50
+ class Sandbox(e2b.Sandbox):
51
+ """Every ``e2b.Sandbox`` method works unchanged; ``live``, ``ssh`` and ``idle_pause`` are added."""
52
+
53
+ @classmethod
54
+ def _resolve_api_params(cls, **opts: Unpack[ApiParams]) -> ApiParams:
55
+ merged = dict(anona_defaults())
56
+ resolved = super()._resolve_api_params(**opts)
57
+ merged.update({k: v for k, v in resolved.items() if v is not None})
58
+ return merged # type: ignore[return-value]
59
+
60
+ @classmethod
61
+ def create(cls, template: str | None = None, *args: Any, size: str | None = None, **kwargs: Any): # type: ignore[override]
62
+ """``e2b.Sandbox.create`` plus ``size``: small, medium, large or xl.
63
+
64
+ A size decides the sandbox's vCPU, memory and disk; without one the
65
+ sandbox runs at its template's own size. A size below the template's
66
+ minimum, or not in your plan, is refused with the sizes that would work.
67
+ """
68
+ if size is not None:
69
+ if len(args) >= 2: # timeout, metadata given positionally
70
+ lst = list(args)
71
+ lst[1] = size_metadata(size, lst[1])
72
+ args = tuple(lst)
73
+ else:
74
+ kwargs["metadata"] = size_metadata(size, kwargs.get("metadata"))
75
+ return super().create(template, *args, **kwargs)
76
+
77
+ @property
78
+ def live(self) -> Live:
79
+ """Live view: ``token()``, ``url()``, ``revoke_all()``."""
80
+ return Live(self.sandbox_id, self.connection_config)
81
+
82
+ @property
83
+ def ssh(self) -> Ssh:
84
+ """SSH: ``certificate(public_key)`` and ``config()``."""
85
+ return Ssh(self.sandbox_id, self.connection_config)
86
+
87
+ @property
88
+ def idle_pause(self) -> IdlePause:
89
+ """Idle pause: ``get()``, ``set(minutes)``, ``off()``.
90
+
91
+ Pauses the sandbox once nothing has used it for that long. Different from
92
+ E2B's ``on_timeout``, which acts when the deadline runs out.
93
+ """
94
+ return IdlePause(self.sandbox_id, self.connection_config)