Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ Versions follow [Semantic Versioning](https://semver.org/).

## [Unreleased]

### Added

- Sandbox access token creation, inspection, rotation, disabling, and a
separate delegated credential handle.

### Fixed

- Remove credentials inherited from supplied HTTP clients on public requests.
Expand Down
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,31 @@ Timeout and retry delays are in seconds.
As an alternative, `Client()` reads `CREATEOS_API_KEY` and
`CREATEOS_SANDBOX_BASE_URL`. Explicit constructor arguments take precedence.

## Delegate access to one sandbox

The owner can create one token for a sandbox. Creation and rotation return the
plaintext token once; inspection returns only a redacted hint.

```python
created = sandbox.create_access_token()
worker = sandbox.with_access_token(created.token)
result = worker.run_command(RunCommandRequest(command="echo", arguments=["hello"]))
print(result.result.standard_output)

metadata = sandbox.get_access_token()
print(metadata.token_hint)
replacement = sandbox.rotate_access_token()
# Give replacement.token to the worker instead of the old token.
sandbox.disable_access_token()
```

Use the owner's handle to manage tokens. A delegated handle can operate its
bound sandbox, including commands, files, processes, computer use, pause,
resume, and destroy; it cannot manage tokens or account resources. Creating
another enabled token returns HTTP 409; rotation requires an existing token.
Disabling is idempotent. Revocation is immediate in the home region and
propagates asynchronously to other regions.

## Documentation

- [CreateOS Sandbox overview](https://createos.sh/docs/Sandbox/Overview)
Expand Down
2 changes: 2 additions & 0 deletions src/createos/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@
RunCommandRequest,
RunCommandResponse,
Sandbox,
SandboxAccessTokenCreateResponse,
SandboxAccessTokenMetadata,
SandboxDisk,
SandboxStatus,
Shape,
Expand Down
11 changes: 11 additions & 0 deletions src/createos/_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,17 @@ def close(self) -> None:
if self._owns_client:
self.client.close()

def with_api_key(self, api_key: str) -> Transport:
"""Share connection settings with a separate credential."""
return Transport(
base_url=self.base_url,
api_key=api_key,
timeout=self.timeout,
user_agent=self.user_agent,
retry=self.retry,
http_client=self.client,
)

def request(
self,
method: str,
Expand Down
2 changes: 1 addition & 1 deletion src/createos/_version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.1.2"
__version__ = "0.2.0"
39 changes: 39 additions & 0 deletions src/createos/instance.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
RunCommandRequest,
RunCommandResponse,
Sandbox,
SandboxAccessTokenCreateResponse,
SandboxAccessTokenMetadata,
SandboxDisk,
SandboxStatus,
WaitOptions,
Expand Down Expand Up @@ -85,6 +87,43 @@ def _update(self, data):
def _path(self, suffix: str) -> str:
return f"/v1/sandboxes/{quote(self.id, safe='')}{suffix}"

def with_access_token(self, token: str) -> SandboxInstance:
"""Return a separate handle using a delegated sandbox token."""
token = token.strip()
if not token:
raise ValueError("sandbox access token must not be empty")
return SandboxInstance(
self._transport.with_api_key(token), self._snapshot()
)

def create_access_token(self) -> SandboxAccessTokenCreateResponse:
"""Create a token; its plaintext value is returned only once."""
return _one(
SandboxAccessTokenCreateResponse,
self._transport.request("POST", self._path("/access-token")),
)

def get_access_token(self) -> SandboxAccessTokenMetadata:
"""Read token state and its redacted hint."""
return _one(
SandboxAccessTokenMetadata,
self._transport.request("GET", self._path("/access-token")),
)

def rotate_access_token(self) -> SandboxAccessTokenCreateResponse:
"""Replace an existing token and return its new plaintext value."""
return _one(
SandboxAccessTokenCreateResponse,
self._transport.request("POST", self._path("/access-token/rotate")),
)

def disable_access_token(self) -> SandboxAccessTokenMetadata:
"""Revoke the current token, if present."""
return _one(
SandboxAccessTokenMetadata,
self._transport.request("DELETE", self._path("/access-token")),
)

def refresh(self) -> SandboxInstance:
"""Reload the server projection and return this handle."""
self._update(
Expand Down
20 changes: 20 additions & 0 deletions src/createos/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -588,6 +588,26 @@ class Sandbox(Model):
auto_pause_after_seconds: int | None = None


@dataclass(slots=True)
class SandboxAccessTokenCreateResponse(Model):
"""Plaintext delegated token returned only on creation or rotation."""

token: str
enabled: bool
created_at: datetime
rotated_at: datetime | None = None


@dataclass(slots=True)
class SandboxAccessTokenMetadata(Model):
"""Token state without the plaintext credential."""

enabled: bool
token_hint: str | None = None
created_at: datetime | None = None
rotated_at: datetime | None = None


@dataclass(slots=True)
class CommandResult(Model):
standard_output: str = _json("stdout", default="")
Expand Down
72 changes: 72 additions & 0 deletions tests/test_sdk.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,78 @@ def mock_client(handler):
return httpx.Client(transport=httpx.MockTransport(handler))


def test_sandbox_access_token_lifecycle_and_scoped_credential():
seen = []
responses = [
envelope({"id": "sb-1", "status": "running"}),
envelope(
{
"token": "skp_sb_first",
"enabled": True,
"created_at": "2026-09-18T10:00:00Z",
}
),
envelope(
{
"enabled": True,
"token_hint": "skp_sb...irst",
"created_at": "2026-09-18T10:00:00Z",
}
),
envelope(
{
"result": {"stdout": "hello\n", "stderr": "", "exit_code": 0},
"exec_ms": 1,
}
),
envelope(
{
"token": "skp_sb_second",
"enabled": True,
"created_at": "2026-09-18T10:00:00Z",
"rotated_at": "2026-09-18T11:00:00Z",
}
),
envelope({"enabled": False}),
]

def handler(request):
seen.append(
(request.method, request.url.path, request.headers["x-api-key"])
)
return responses.pop(0)

client = Client(
api_key="owner",
base_url="https://example.test",
http_client=mock_client(handler),
)
sandbox = client.get_sandbox("sb-1")
created = sandbox.create_access_token()
assert created.token == "skp_sb_first" and created.created_at.year == 2026
assert sandbox.get_access_token().token_hint == "skp_sb...irst"
worker = sandbox.with_access_token(created.token)
assert worker is not sandbox and worker.files is not sandbox.files
assert (
worker.run_command(
RunCommandRequest(command="echo", arguments=["hello"])
).result.standard_output
== "hello\n"
)
assert sandbox.rotate_access_token().rotated_at.hour == 11
assert sandbox.disable_access_token().enabled is False
assert seen == [
("GET", "/v1/sandboxes/sb-1", "owner"),
("POST", "/v1/sandboxes/sb-1/access-token", "owner"),
("GET", "/v1/sandboxes/sb-1/access-token", "owner"),
("POST", "/v1/sandboxes/sb-1/exec", "skp_sb_first"),
("POST", "/v1/sandboxes/sb-1/access-token/rotate", "owner"),
("DELETE", "/v1/sandboxes/sb-1/access-token", "owner"),
]
with pytest.raises(ValueError):
sandbox.with_access_token(" ")


def test_health_omits_auth_and_whoami_sends_it():
seen = []

Expand Down
Loading