diff --git a/CHANGELOG.md b/CHANGELOG.md index ad88e4f..4ca72f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index c1d041c..9e6c794 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/src/createos/__init__.py b/src/createos/__init__.py index a83c9e2..cc98c09 100644 --- a/src/createos/__init__.py +++ b/src/createos/__init__.py @@ -89,6 +89,8 @@ RunCommandRequest, RunCommandResponse, Sandbox, + SandboxAccessTokenCreateResponse, + SandboxAccessTokenMetadata, SandboxDisk, SandboxStatus, Shape, diff --git a/src/createos/_transport.py b/src/createos/_transport.py index 04c168e..c12d23f 100644 --- a/src/createos/_transport.py +++ b/src/createos/_transport.py @@ -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, diff --git a/src/createos/_version.py b/src/createos/_version.py index b3f4756..d3ec452 100644 --- a/src/createos/_version.py +++ b/src/createos/_version.py @@ -1 +1 @@ -__version__ = "0.1.2" +__version__ = "0.2.0" diff --git a/src/createos/instance.py b/src/createos/instance.py index 03491ba..5ceec36 100644 --- a/src/createos/instance.py +++ b/src/createos/instance.py @@ -24,6 +24,8 @@ RunCommandRequest, RunCommandResponse, Sandbox, + SandboxAccessTokenCreateResponse, + SandboxAccessTokenMetadata, SandboxDisk, SandboxStatus, WaitOptions, @@ -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( diff --git a/src/createos/models.py b/src/createos/models.py index 0355425..e14278b 100644 --- a/src/createos/models.py +++ b/src/createos/models.py @@ -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="") diff --git a/tests/test_sdk.py b/tests/test_sdk.py index 15e7fd7..c291147 100644 --- a/tests/test_sdk.py +++ b/tests/test_sdk.py @@ -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 = []