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
98 changes: 98 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,104 @@ client.volumes.delete(same_volume.id)
client.close()
```

### Manage secrets and pass them to sandbox processes

Secrets hold values such as API keys for sandbox processes. Values are
encrypted on your machine to the platform's public key before upload, and the
API never returns them. Methods that take a secret accept its ID or exact name.

```python
from pathlib import Path

from hyperbrowser import Hyperbrowser

client = Hyperbrowser(api_key="test-key")

# Values are str or bytes and are stored byte-for-byte, trailing newlines included.
client.secrets.create(
{
"name": "provider-key",
"description": "Provider API key",
"value": Path("provider-key.txt").read_bytes(),
}
)
client.secrets.add_version("provider-key", "rotated-value") # becomes version 2
detail = client.secrets.get("provider-key") # metadata and versions, never the value
client.secrets.list({"search": "provider"})

sandbox = client.sandboxes.create(
{
"image_name": "node",
# Delivered to the image's startup command (image launches only).
"secrets": {"API_KEY": "provider-key"},
}
)

# A plain name uses the latest version; {"name": ..., "version": N} pins one.
result = sandbox.exec(
"node app.js",
secrets={"API_KEY": {"name": "provider-key", "version": 2}},
)
process = sandbox.processes.start("node worker.js", secrets={"API_KEY": "provider-key"})
print(process.secrets) # the version each variable received

client.secrets.delete_version("provider-key", 1)
client.secrets.delete("provider-key")
client.close()
```

Things to know:

- A secret is set only in the environment of the process it was passed to.
Anything else running as the same user, or as root, in the sandbox can read
that environment.
- A sandbox that has received secrets, and snapshots taken of it, report
`contains_secrets=True`. Restoring such a snapshot needs the same access as
passing secrets.
- Deleting a secret or version doesn't revoke a value already delivered to a
sandbox.
- Limits: 100 secrets per team, 10 versions per secret, 32 secrets per process,
and values of 1 byte to 32 KiB of UTF-8 without NUL bytes. Names are 1-128
letters, digits, `_` or `-`, starting with a letter or digit.
- Reading secrets needs `secrets.read`; delivering them to a sandbox needs
`secrets.use`. Creating a secret needs `secrets.manage`. Updating, rotating,
and deleting secrets or versions through the SDK need both `secrets.read`
and `secrets.manage`, since it resolves IDs and names through metadata reads.
`add_version()` also reads the secret's `encryptionContext` to encrypt its
value. The team needs the secrets feature.
- Secret-started process handles wait indefinitely by default with `wait()` or
`result()`. A positive `wait(timeout_ms=...)` or `wait(timeout_sec=...)` limits
the total local wait and raises `TimeoutError` (`asyncio.TimeoutError` for
async handles); the process continues and the handle can be used to wait
again. The start's timeout limits the command's execution separately.
The sync client checks the limit between requests and caps each request's
HTTP timeouts at the time left, so a slow response or a runtime token
refresh can overrun it; use the async client for a strict limit.
Secret process output comes from the receiver's retained output, so
`max_output_bytes` does not apply; incomplete retained output raises an error.
- A process start with secrets that times out, or whose result the API can't
confirm, raises a non-retryable error with code
`secret_process_outcome_unknown`: the command may be running, so check
`sandbox.processes.list()` before retrying. When the API reported the
unknown outcome itself, its response body, including its own code
(`SECRET_PROCESS_OUTCOME_UNKNOWN`), is in the error's `details`.
- `client.sandboxes.create()` with `secrets` raises the same
`secret_process_outcome_unknown` error when the create may have reached the
API but its result is unknown (a timeout, a dropped connection, or an
unreadable response): a sandbox may be running its startup command with the
secrets, so list sandboxes before retrying.
- Error codes from the API:
- `SECRETS_ADMIN_REQUIRED` (403): the request used an `hx auth login`
session of a team member who isn't an owner or admin. API keys are
governed by their scopes instead.
- `SECRETS_FEATURE_DISABLED` (403): the team doesn't have the secrets
feature.
- `SECRETS_NOT_CONFIGURED` (503): the platform has no secrets key
configured. It isn't retried.
- `SECRET_STARTUP_UNSUPPORTED` (400): the image has no startup command to
deliver `secrets` to. Create the sandbox without them, then pass them to
`sandbox.exec()` or `sandbox.processes.start()`.

### List sandboxes with filters

```python
Expand Down
2 changes: 2 additions & 0 deletions hyperbrowser/client/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from .managers.async_manager.extract import ExtractManager
from .managers.async_manager.profile import ProfileManager
from .managers.async_manager.sandbox import SandboxManager
from .managers.async_manager.secret import SecretManager
from .managers.async_manager.scrape import ScrapeManager
from .managers.async_manager.session import SessionManager
from .managers.async_manager.team import TeamManager
Expand Down Expand Up @@ -49,6 +50,7 @@ def __init__(
self.computer_action = ComputerActionManager(self)
self.sandboxes = SandboxManager(self)
self.volumes = VolumeManager(self)
self.secrets = SecretManager(self)

async def close(self) -> None:
await self.transport.close()
Expand Down
67 changes: 60 additions & 7 deletions hyperbrowser/client/managers/async_manager/sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,13 @@
StartSandboxFromSnapshotParams,
)
from ....models.session import BasicResponse
from ....secrets_common import (
missing_process_id_error,
request_may_have_arrived,
sandbox_create_outcome_unknown_error,
secret_process_id,
secret_process_start_error,
)
from ....types import (
CompleteSandboxImageBuildParams as CompleteSandboxImageBuildParamsDict,
CreateSandboxImageBuildParams as CreateSandboxImageBuildParamsDict,
Expand Down Expand Up @@ -72,6 +79,7 @@
completed_image_id,
)
from ..sandboxes.shared import (
SecretReferences,
_build_sandbox_exposed_url,
_copy_model,
_expires_within_buffer,
Expand Down Expand Up @@ -143,7 +151,11 @@ def __init__(self, service: "SandboxManager", detail: SandboxDetail):
service.runtime_timeout,
service.runtime_proxy_override,
)
self.processes = SandboxProcessesApi(self._transport)
self.processes = SandboxProcessesApi(
self._transport,
self._start_secret_process,
detail.id,
)
self.files = SandboxFilesApi(
self._transport,
self._resolve_runtime_socket_info,
Expand Down Expand Up @@ -318,7 +330,16 @@ async def exec(
timeout_sec: Optional[int] = None,
run_as: Optional[str] = None,
max_output_bytes: int = 64 * 1024 * 1024,
secrets: Optional[SecretReferences] = None,
):
"""Run a command to completion.

``secrets`` maps environment variable names to stored secrets (a name,
or ``{"name": ..., "version": N}``). Anything else running as the
same user, or as root, in the sandbox can read a process's
environment, and the sandbox and its later snapshots are marked as
containing secrets.
"""
return await self.processes.exec(
input,
cwd=cwd,
Expand All @@ -327,8 +348,12 @@ async def exec(
timeout_sec=timeout_sec,
run_as=run_as,
max_output_bytes=max_output_bytes,
secrets=secrets,
)

async def _start_secret_process(self, payload: Dict[str, object]) -> dict:
return await self._service.start_secret_process(self.id, payload)

async def get_process(self, process_id: str) -> SandboxProcessHandle:
return await self.processes.get(process_id)

Expand Down Expand Up @@ -1127,16 +1152,44 @@ async def unexpose(self, sandbox_id: str, port: int) -> SandboxUnexposeResult:
)
return SandboxUnexposeResult(**payload)

async def start_secret_process(
self, sandbox_id: str, payload: Dict[str, object]
) -> dict:
"""Start a sandbox process with secrets through the control API.

Returns the raw ``{"process": ..., "secrets": [...]}`` response. Prefer
``sandbox.exec(..., secrets=...)`` or ``sandbox.processes.start``.
"""
try:
response = await self._request(
"POST", f"/sandbox/{sandbox_id}/processes", data=payload
)
except HyperbrowserError as error:
raise secret_process_start_error(sandbox_id, error)
process = response.get("process") if isinstance(response, dict) else None
process_id = secret_process_id(process)
if not process_id:
raise missing_process_id_error(sandbox_id)
return dict(response, process=dict(process, id=process_id))

async def _create_detail(
self,
params: Union[CreateSandboxParamsDict, CreateSandboxParams],
) -> SandboxDetail:
payload = await self._request(
"POST",
"/sandbox",
data=dump_request(params, CreateSandboxParams),
)
return SandboxDetail(**payload)
normalized = coerce_request(params, CreateSandboxParams)
data = dump_request(normalized, CreateSandboxParams)
try:
payload = await self._request("POST", "/sandbox", data=data)
detail = SandboxDetail(**payload)
if normalized.secrets and not detail.id.strip():
raise ValueError("The API accepted the create but returned no sandbox ID")
return detail
except Exception as error:
# A create that may have reached the API may be running its
# startup command with the secrets, so it must not look retryable.
if normalized.secrets and request_may_have_arrived(error):
raise sandbox_create_outcome_unknown_error(error)
raise

async def _request(
self,
Expand Down
Loading