instabox 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- instabox/__init__.py +84 -0
- instabox/_base.py +67 -0
- instabox/_policy.py +142 -0
- instabox/async_client.py +726 -0
- instabox/async_machine.py +175 -0
- instabox/client.py +792 -0
- instabox/exceptions.py +144 -0
- instabox/machine.py +195 -0
- instabox/mcp.py +196 -0
- instabox/models.py +449 -0
- instabox-0.1.0.dist-info/METADATA +449 -0
- instabox-0.1.0.dist-info/RECORD +15 -0
- instabox-0.1.0.dist-info/WHEEL +4 -0
- instabox-0.1.0.dist-info/entry_points.txt +2 -0
- instabox-0.1.0.dist-info/licenses/LICENSE +201 -0
instabox/__init__.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Python SDK for the InstaBox API (https://instabox.sh).
|
|
2
|
+
|
|
3
|
+
InstaBox gives every agent a Linux machine of its own in the cloud.
|
|
4
|
+
This SDK wraps the REST API: create, start, stop, pause and resume machines,
|
|
5
|
+
run commands, read and write files, take snapshots, publish ports and track
|
|
6
|
+
async operations.
|
|
7
|
+
|
|
8
|
+
Quick start::
|
|
9
|
+
|
|
10
|
+
from instabox import Client
|
|
11
|
+
|
|
12
|
+
client = Client(api_key="ibk_...")
|
|
13
|
+
op = client.machines.create(name="dev")
|
|
14
|
+
op = client.operations.wait(op.id)
|
|
15
|
+
result = client.machines.exec("dev", cmd="echo hello")
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from ._policy import ToolPolicy
|
|
21
|
+
from .async_client import AsyncClient
|
|
22
|
+
from .async_machine import AsyncMachineHandle
|
|
23
|
+
from .client import Client
|
|
24
|
+
from .exceptions import (
|
|
25
|
+
APIError,
|
|
26
|
+
AuthenticationError,
|
|
27
|
+
BadRequestError,
|
|
28
|
+
ConflictError,
|
|
29
|
+
InstaboxError,
|
|
30
|
+
NotFoundError,
|
|
31
|
+
PaymentRequiredError,
|
|
32
|
+
PermissionDeniedError,
|
|
33
|
+
RateLimitError,
|
|
34
|
+
ServerError,
|
|
35
|
+
TimeoutExceededError,
|
|
36
|
+
)
|
|
37
|
+
from .machine import MachineHandle
|
|
38
|
+
from .models import (
|
|
39
|
+
CurrentUser,
|
|
40
|
+
ExecResult,
|
|
41
|
+
FileEntry,
|
|
42
|
+
FileListing,
|
|
43
|
+
Machine,
|
|
44
|
+
Operation,
|
|
45
|
+
Port,
|
|
46
|
+
Snapshot,
|
|
47
|
+
Spec,
|
|
48
|
+
Template,
|
|
49
|
+
UploadUrl,
|
|
50
|
+
Usage,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
__version__ = "0.1.0"
|
|
54
|
+
__all__ = [
|
|
55
|
+
"APIError",
|
|
56
|
+
"AsyncClient",
|
|
57
|
+
"AsyncMachineHandle",
|
|
58
|
+
"AuthenticationError",
|
|
59
|
+
"BadRequestError",
|
|
60
|
+
"Client",
|
|
61
|
+
"ConflictError",
|
|
62
|
+
"CurrentUser",
|
|
63
|
+
"ExecResult",
|
|
64
|
+
"FileEntry",
|
|
65
|
+
"FileListing",
|
|
66
|
+
"InstaboxError",
|
|
67
|
+
"Machine",
|
|
68
|
+
"MachineHandle",
|
|
69
|
+
"NotFoundError",
|
|
70
|
+
"Operation",
|
|
71
|
+
"PaymentRequiredError",
|
|
72
|
+
"PermissionDeniedError",
|
|
73
|
+
"Port",
|
|
74
|
+
"RateLimitError",
|
|
75
|
+
"ServerError",
|
|
76
|
+
"Snapshot",
|
|
77
|
+
"Spec",
|
|
78
|
+
"Template",
|
|
79
|
+
"TimeoutExceededError",
|
|
80
|
+
"ToolPolicy",
|
|
81
|
+
"UploadUrl",
|
|
82
|
+
"Usage",
|
|
83
|
+
"__version__",
|
|
84
|
+
]
|
instabox/_base.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Shared internals: headers, options and small helpers used by both clients."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typing as t
|
|
6
|
+
import uuid
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
DEFAULT_BASE_URL = "https://api.instabox.sh"
|
|
11
|
+
LONG_POLL_SECONDS = "25s" # max allowed by GET /v1/operations?wait=
|
|
12
|
+
UPLOAD_LIMIT_BYTES = 5 * 1024 * 1024 * 1024 # 5 GiB
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class ClientOptions:
|
|
17
|
+
"""Common configuration shared by the sync and async clients."""
|
|
18
|
+
|
|
19
|
+
api_key: str
|
|
20
|
+
base_url: str = DEFAULT_BASE_URL
|
|
21
|
+
timeout: float = 60.0
|
|
22
|
+
default_headers: dict[str, str] | None = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def new_idempotency_key() -> str:
|
|
26
|
+
"""Generate a fresh idempotency key (any unique string works)."""
|
|
27
|
+
return str(uuid.uuid4())
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def build_headers(api_key: str, extra: dict[str, str] | None = None) -> dict[str, str]:
|
|
31
|
+
"""Build authentication and content headers for a request."""
|
|
32
|
+
headers = {"X-API-Key": api_key, "Content-Type": "application/json"}
|
|
33
|
+
if extra:
|
|
34
|
+
headers.update(extra)
|
|
35
|
+
return headers
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def json_body(data: dict[str, Any] | None) -> dict[str, Any]:
|
|
39
|
+
"""Drop ``None`` values from a request body so omitted fields stay unchanged."""
|
|
40
|
+
return {k: v for k, v in (data or {}).items() if v is not None}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def parse_sse(lines: t.Iterable[str]) -> t.Iterator[tuple[str, str]]:
|
|
44
|
+
"""Parse a ``text/event-stream`` into ``(event, data)`` pairs.
|
|
45
|
+
|
|
46
|
+
Handles multi-line ``data:`` payloads (joined with newlines), comments and
|
|
47
|
+
blank-line event separators, per the SSE specification.
|
|
48
|
+
"""
|
|
49
|
+
event = ""
|
|
50
|
+
data: list[str] = []
|
|
51
|
+
for line in lines:
|
|
52
|
+
line = line.rstrip("\r")
|
|
53
|
+
if not line:
|
|
54
|
+
if data:
|
|
55
|
+
yield event or "message", "\n".join(data)
|
|
56
|
+
event, data = "", []
|
|
57
|
+
continue
|
|
58
|
+
if line.startswith(":"):
|
|
59
|
+
continue # comment
|
|
60
|
+
field, _, value = line.partition(":")
|
|
61
|
+
value = value.lstrip(" ")
|
|
62
|
+
if field == "event":
|
|
63
|
+
event = value
|
|
64
|
+
elif field == "data":
|
|
65
|
+
data.append(value)
|
|
66
|
+
if data:
|
|
67
|
+
yield event or "message", "\n".join(data)
|
instabox/_policy.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""SDK-level tool policy: restrict which operations a client may perform.
|
|
2
|
+
|
|
3
|
+
Two layers of access control exist for InstaBox:
|
|
4
|
+
|
|
5
|
+
1. **API key scopes** (server-side, authoritative): keys carry scopes such as
|
|
6
|
+
``machines:read``, ``machines:write``, ``machines:delete``, ``images:read``
|
|
7
|
+
and ``workspace:admin``. A request outside a key's scopes fails with
|
|
8
|
+
``403 missing_scope``. Create scoped keys in the dashboard or with
|
|
9
|
+
``instabox keys create --scope ...``.
|
|
10
|
+
|
|
11
|
+
2. **SDK tool policy** (client-side, this module): an allow/deny list of
|
|
12
|
+
operation names such as ``machines.create`` or ``machines.exec``. Use it
|
|
13
|
+
when one code path (e.g. an AI agent) shares a broader key but must not
|
|
14
|
+
touch certain operations — the SDK raises
|
|
15
|
+
:class:`instabox.exceptions.PermissionDeniedError` before any network
|
|
16
|
+
request is made.
|
|
17
|
+
|
|
18
|
+
Operation names follow ``<resource>.<method>``: ``machines.*``,
|
|
19
|
+
``operations.*``, ``snapshots.*``, ``ports.*``, ``account.*``, plus
|
|
20
|
+
``machines.ssh_tokens.*``, ``machines.secrets.*`` and ``orgs.secrets.*``
|
|
21
|
+
aliases for the nested resources.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import builtins
|
|
27
|
+
import inspect
|
|
28
|
+
from collections.abc import Callable
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
from .exceptions import PermissionDeniedError
|
|
32
|
+
|
|
33
|
+
# Nested namespaces that are checked as their own prefix, in addition to the
|
|
34
|
+
# resource class name under which they live.
|
|
35
|
+
_ALIASES = {
|
|
36
|
+
"SshTokens": "machines.ssh_tokens",
|
|
37
|
+
"MachineSecrets": "machines.secrets",
|
|
38
|
+
"OrgSecrets": "orgs.secrets",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
_POLICY_ATTR = "_instabox_policy_prefix"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def operation_name(resource_class_name: str, method_name: str) -> str:
|
|
45
|
+
"""Return the dotted operation name for a resource method."""
|
|
46
|
+
base = resource_class_name.lstrip("_")
|
|
47
|
+
if base.startswith("Async"):
|
|
48
|
+
base = base[len("Async") :]
|
|
49
|
+
prefix = _ALIASES.get(resource_class_name, base.lower())
|
|
50
|
+
return f"{prefix}.{method_name}"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def check_allowed(policy: ToolPolicy | None, name: str) -> None:
|
|
54
|
+
"""Raise :class:`PermissionDeniedError` if the policy disallows ``name``."""
|
|
55
|
+
if policy is not None and not policy.allows(name):
|
|
56
|
+
raise PermissionDeniedError(
|
|
57
|
+
status=403,
|
|
58
|
+
code="sdk_not_allowed",
|
|
59
|
+
message=(
|
|
60
|
+
f"operation '{name}' is blocked by this client's tool policy "
|
|
61
|
+
f"(allowed: {builtins.sorted(policy.allow) or '<none>'})"
|
|
62
|
+
),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class ToolPolicy:
|
|
67
|
+
"""Allow/deny list of operation names (``resource.method`` strings).
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
allow: If given, only these operations (or prefixes ending in ``.*``)
|
|
71
|
+
are permitted. ``None`` means everything is allowed unless denied.
|
|
72
|
+
deny: Operations (or prefixes) always forbidden; wins over ``allow``.
|
|
73
|
+
|
|
74
|
+
Example::
|
|
75
|
+
|
|
76
|
+
policy = ToolPolicy(
|
|
77
|
+
allow=["machines.exec", "machines.read_file", "operations.*"],
|
|
78
|
+
deny=["machines.delete"],
|
|
79
|
+
)
|
|
80
|
+
client = Client(api_key=key, policy=policy)
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
__slots__ = ("allow", "deny")
|
|
84
|
+
|
|
85
|
+
def __init__(
|
|
86
|
+
self,
|
|
87
|
+
allow: builtins.set[str] | builtins.list[str] | None = None,
|
|
88
|
+
deny: builtins.set[str] | builtins.list[str] | None = None,
|
|
89
|
+
) -> None:
|
|
90
|
+
self.allow: frozenset[str] = frozenset(allow or ())
|
|
91
|
+
self.deny: frozenset[str] = frozenset(deny or ())
|
|
92
|
+
|
|
93
|
+
def _match(self, names: frozenset[str], name: str) -> bool:
|
|
94
|
+
if name in names:
|
|
95
|
+
return True
|
|
96
|
+
prefix = name + "."
|
|
97
|
+
return any(n.endswith(".*") and prefix.startswith(n[:-1]) for n in names)
|
|
98
|
+
|
|
99
|
+
def allows(self, name: str) -> bool:
|
|
100
|
+
if self._match(self.deny, name):
|
|
101
|
+
return False
|
|
102
|
+
if not self.allow: # nothing specified -> allow all (minus deny)
|
|
103
|
+
return True
|
|
104
|
+
return self._match(self.allow, name)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def wrap_resource_methods(resource: Any) -> None:
|
|
108
|
+
"""Wrap each public method of ``resource`` with a policy check.
|
|
109
|
+
|
|
110
|
+
The operation name is derived from the resource class name and the method
|
|
111
|
+
name; the check runs before any request is made.
|
|
112
|
+
"""
|
|
113
|
+
cls = resource.__class__
|
|
114
|
+
if getattr(cls, "_policy_wrapped", False):
|
|
115
|
+
return
|
|
116
|
+
cls._policy_wrapped = True
|
|
117
|
+
prefix = operation_name(cls.__name__, "")
|
|
118
|
+
for attr, value in vars(cls).items():
|
|
119
|
+
if attr.startswith("_") or not inspect.isfunction(value):
|
|
120
|
+
continue
|
|
121
|
+
name = f"{prefix}{attr}"
|
|
122
|
+
|
|
123
|
+
def make_wrapper(func: Callable[..., Any], op_name: str) -> Callable[..., Any]:
|
|
124
|
+
def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any:
|
|
125
|
+
check_allowed(getattr(self, "_policy", None), op_name)
|
|
126
|
+
return func(self, *args, **kwargs)
|
|
127
|
+
|
|
128
|
+
if inspect.iscoroutinefunction(func):
|
|
129
|
+
|
|
130
|
+
async def async_wrapper(self: Any, *args: Any, **kwargs: Any) -> Any:
|
|
131
|
+
check_allowed(getattr(self, "_policy", None), op_name)
|
|
132
|
+
return await func(self, *args, **kwargs)
|
|
133
|
+
|
|
134
|
+
async_wrapper.__name__ = func.__name__
|
|
135
|
+
async_wrapper.__doc__ = func.__doc__
|
|
136
|
+
return async_wrapper
|
|
137
|
+
|
|
138
|
+
wrapper.__name__ = func.__name__
|
|
139
|
+
wrapper.__doc__ = func.__doc__
|
|
140
|
+
return wrapper
|
|
141
|
+
|
|
142
|
+
setattr(cls, attr, make_wrapper(value, name))
|