Skip to content

Commit f8a7987

Browse files
AHarmlessPyroclaudeNikhilShahi
authored
Add secrets management and secret delivery to sandboxes (#139)
* Add secrets management and secret delivery to sandboxes Adds client.secrets (sync and async) for the API-key routes: public key, list, create, get, update, add_version, delete and delete_version. Values are encrypted client-side as a compact JWE (RSA-OAEP-256 + A256GCM) bound to the secret's encryption context, after checking the key's policy and RFC 7638 key ID. Secrets resolve by ID or exact name. Sandbox image launches accept `secrets` for the startup command, and exec/processes.start accept `secrets`, starting through the control API and waiting through the runtime. Sandboxes and snapshots expose contains_secrets. Control API errors honour the server's `retryable` flag and a machine code sent as `error`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NhbFMc29f5JN5JxCciajJV * Fix secret process deadlines and ambiguous outcome handling * Reconcile secrets with the hx CLI and current server - Treat only 8-4-4-4-12 keys as secret IDs, so UUID-like names fall back to the name search instead of failing on the server's 400. - A sandbox create with secrets whose outcome is unknown raises a non-retryable secret_process_outcome_unknown: a sandbox may be running its startup command with the secrets. - Normalize the server's SECRET_PROCESS_OUTCOME_UNKNOWN to the documented code, keeping the server body in details. - Add the sandbox and secret context to control error messages, as the CLI does. - Without a server retryable flag, SECRETS_NOT_CONFIGURED and other codes retrying can't fix are not retryable. - Reject use_shell with secrets, accept processId in start responses, and check secret names, env names, versions and list bounds locally. - Document SECRETS_ADMIN_REQUIRED, SECRETS_FEATURE_DISABLED, SECRETS_NOT_CONFIGURED and SECRET_STARTUP_UNSUPPORTED. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Drop the sync client's socket-level wait deadlines Interrupting an in-flight HTTPX request needed a timer thread that shut down sockets through trace hooks, with special cases for shared HTTP/2 connections, TLS stream swaps and trickled responses, plus deadline-aware copies of the token refresh and control request paths. The sync secret process wait now checks its limit between requests and caps each request's HTTP timeouts at the time left, like the async client's transport. A trickled response or a token refresh can overrun the limit; the README points to the async client for a strict one. The control request path is back to master's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Harden secret validation and ambiguous sandbox outcomes --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: nikhil <nshahi1998@gmail.com>
1 parent 4427393 commit f8a7987

28 files changed

Lines changed: 3978 additions & 61 deletions

‎README.md‎

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,104 @@ client.volumes.delete(same_volume.id)
261261
client.close()
262262
```
263263

264+
### Manage secrets and pass them to sandbox processes
265+
266+
Secrets hold values such as API keys for sandbox processes. Values are
267+
encrypted on your machine to the platform's public key before upload, and the
268+
API never returns them. Methods that take a secret accept its ID or exact name.
269+
270+
```python
271+
from pathlib import Path
272+
273+
from hyperbrowser import Hyperbrowser
274+
275+
client = Hyperbrowser(api_key="test-key")
276+
277+
# Values are str or bytes and are stored byte-for-byte, trailing newlines included.
278+
client.secrets.create(
279+
{
280+
"name": "provider-key",
281+
"description": "Provider API key",
282+
"value": Path("provider-key.txt").read_bytes(),
283+
}
284+
)
285+
client.secrets.add_version("provider-key", "rotated-value") # becomes version 2
286+
detail = client.secrets.get("provider-key") # metadata and versions, never the value
287+
client.secrets.list({"search": "provider"})
288+
289+
sandbox = client.sandboxes.create(
290+
{
291+
"image_name": "node",
292+
# Delivered to the image's startup command (image launches only).
293+
"secrets": {"API_KEY": "provider-key"},
294+
}
295+
)
296+
297+
# A plain name uses the latest version; {"name": ..., "version": N} pins one.
298+
result = sandbox.exec(
299+
"node app.js",
300+
secrets={"API_KEY": {"name": "provider-key", "version": 2}},
301+
)
302+
process = sandbox.processes.start("node worker.js", secrets={"API_KEY": "provider-key"})
303+
print(process.secrets) # the version each variable received
304+
305+
client.secrets.delete_version("provider-key", 1)
306+
client.secrets.delete("provider-key")
307+
client.close()
308+
```
309+
310+
Things to know:
311+
312+
- A secret is set only in the environment of the process it was passed to.
313+
Anything else running as the same user, or as root, in the sandbox can read
314+
that environment.
315+
- A sandbox that has received secrets, and snapshots taken of it, report
316+
`contains_secrets=True`. Restoring such a snapshot needs the same access as
317+
passing secrets.
318+
- Deleting a secret or version doesn't revoke a value already delivered to a
319+
sandbox.
320+
- Limits: 100 secrets per team, 10 versions per secret, 32 secrets per process,
321+
and values of 1 byte to 32 KiB of UTF-8 without NUL bytes. Names are 1-128
322+
letters, digits, `_` or `-`, starting with a letter or digit.
323+
- Reading secrets needs `secrets.read`; delivering them to a sandbox needs
324+
`secrets.use`. Creating a secret needs `secrets.manage`. Updating, rotating,
325+
and deleting secrets or versions through the SDK need both `secrets.read`
326+
and `secrets.manage`, since it resolves IDs and names through metadata reads.
327+
`add_version()` also reads the secret's `encryptionContext` to encrypt its
328+
value. The team needs the secrets feature.
329+
- Secret-started process handles wait indefinitely by default with `wait()` or
330+
`result()`. A positive `wait(timeout_ms=...)` or `wait(timeout_sec=...)` limits
331+
the total local wait and raises `TimeoutError` (`asyncio.TimeoutError` for
332+
async handles); the process continues and the handle can be used to wait
333+
again. The start's timeout limits the command's execution separately.
334+
The sync client checks the limit between requests and caps each request's
335+
HTTP timeouts at the time left, so a slow response or a runtime token
336+
refresh can overrun it; use the async client for a strict limit.
337+
Secret process output comes from the receiver's retained output, so
338+
`max_output_bytes` does not apply; incomplete retained output raises an error.
339+
- A process start with secrets that times out, or whose result the API can't
340+
confirm, raises a non-retryable error with code
341+
`secret_process_outcome_unknown`: the command may be running, so check
342+
`sandbox.processes.list()` before retrying. When the API reported the
343+
unknown outcome itself, its response body, including its own code
344+
(`SECRET_PROCESS_OUTCOME_UNKNOWN`), is in the error's `details`.
345+
- `client.sandboxes.create()` with `secrets` raises the same
346+
`secret_process_outcome_unknown` error when the create may have reached the
347+
API but its result is unknown (a timeout, a dropped connection, or an
348+
unreadable response): a sandbox may be running its startup command with the
349+
secrets, so list sandboxes before retrying.
350+
- Error codes from the API:
351+
- `SECRETS_ADMIN_REQUIRED` (403): the request used an `hx auth login`
352+
session of a team member who isn't an owner or admin. API keys are
353+
governed by their scopes instead.
354+
- `SECRETS_FEATURE_DISABLED` (403): the team doesn't have the secrets
355+
feature.
356+
- `SECRETS_NOT_CONFIGURED` (503): the platform has no secrets key
357+
configured. It isn't retried.
358+
- `SECRET_STARTUP_UNSUPPORTED` (400): the image has no startup command to
359+
deliver `secrets` to. Create the sandbox without them, then pass them to
360+
`sandbox.exec()` or `sandbox.processes.start()`.
361+
264362
### List sandboxes with filters
265363

266364
```python

‎hyperbrowser/client/async_client.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from .managers.async_manager.extract import ExtractManager
1111
from .managers.async_manager.profile import ProfileManager
1212
from .managers.async_manager.sandbox import SandboxManager
13+
from .managers.async_manager.secret import SecretManager
1314
from .managers.async_manager.scrape import ScrapeManager
1415
from .managers.async_manager.session import SessionManager
1516
from .managers.async_manager.team import TeamManager
@@ -49,6 +50,7 @@ def __init__(
4950
self.computer_action = ComputerActionManager(self)
5051
self.sandboxes = SandboxManager(self)
5152
self.volumes = VolumeManager(self)
53+
self.secrets = SecretManager(self)
5254

5355
async def close(self) -> None:
5456
await self.transport.close()

‎hyperbrowser/client/managers/async_manager/sandbox.py‎

Lines changed: 60 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,13 @@
4141
StartSandboxFromSnapshotParams,
4242
)
4343
from ....models.session import BasicResponse
44+
from ....secrets_common import (
45+
missing_process_id_error,
46+
request_may_have_arrived,
47+
sandbox_create_outcome_unknown_error,
48+
secret_process_id,
49+
secret_process_start_error,
50+
)
4451
from ....types import (
4552
CompleteSandboxImageBuildParams as CompleteSandboxImageBuildParamsDict,
4653
CreateSandboxImageBuildParams as CreateSandboxImageBuildParamsDict,
@@ -72,6 +79,7 @@
7279
completed_image_id,
7380
)
7481
from ..sandboxes.shared import (
82+
SecretReferences,
7583
_build_sandbox_exposed_url,
7684
_copy_model,
7785
_expires_within_buffer,
@@ -143,7 +151,11 @@ def __init__(self, service: "SandboxManager", detail: SandboxDetail):
143151
service.runtime_timeout,
144152
service.runtime_proxy_override,
145153
)
146-
self.processes = SandboxProcessesApi(self._transport)
154+
self.processes = SandboxProcessesApi(
155+
self._transport,
156+
self._start_secret_process,
157+
detail.id,
158+
)
147159
self.files = SandboxFilesApi(
148160
self._transport,
149161
self._resolve_runtime_socket_info,
@@ -318,7 +330,16 @@ async def exec(
318330
timeout_sec: Optional[int] = None,
319331
run_as: Optional[str] = None,
320332
max_output_bytes: int = 64 * 1024 * 1024,
333+
secrets: Optional[SecretReferences] = None,
321334
):
335+
"""Run a command to completion.
336+
337+
``secrets`` maps environment variable names to stored secrets (a name,
338+
or ``{"name": ..., "version": N}``). Anything else running as the
339+
same user, or as root, in the sandbox can read a process's
340+
environment, and the sandbox and its later snapshots are marked as
341+
containing secrets.
342+
"""
322343
return await self.processes.exec(
323344
input,
324345
cwd=cwd,
@@ -327,8 +348,12 @@ async def exec(
327348
timeout_sec=timeout_sec,
328349
run_as=run_as,
329350
max_output_bytes=max_output_bytes,
351+
secrets=secrets,
330352
)
331353

354+
async def _start_secret_process(self, payload: Dict[str, object]) -> dict:
355+
return await self._service.start_secret_process(self.id, payload)
356+
332357
async def get_process(self, process_id: str) -> SandboxProcessHandle:
333358
return await self.processes.get(process_id)
334359

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

1155+
async def start_secret_process(
1156+
self, sandbox_id: str, payload: Dict[str, object]
1157+
) -> dict:
1158+
"""Start a sandbox process with secrets through the control API.
1159+
1160+
Returns the raw ``{"process": ..., "secrets": [...]}`` response. Prefer
1161+
``sandbox.exec(..., secrets=...)`` or ``sandbox.processes.start``.
1162+
"""
1163+
try:
1164+
response = await self._request(
1165+
"POST", f"/sandbox/{sandbox_id}/processes", data=payload
1166+
)
1167+
except HyperbrowserError as error:
1168+
raise secret_process_start_error(sandbox_id, error)
1169+
process = response.get("process") if isinstance(response, dict) else None
1170+
process_id = secret_process_id(process)
1171+
if not process_id:
1172+
raise missing_process_id_error(sandbox_id)
1173+
return dict(response, process=dict(process, id=process_id))
1174+
11301175
async def _create_detail(
11311176
self,
11321177
params: Union[CreateSandboxParamsDict, CreateSandboxParams],
11331178
) -> SandboxDetail:
1134-
payload = await self._request(
1135-
"POST",
1136-
"/sandbox",
1137-
data=dump_request(params, CreateSandboxParams),
1138-
)
1139-
return SandboxDetail(**payload)
1179+
normalized = coerce_request(params, CreateSandboxParams)
1180+
data = dump_request(normalized, CreateSandboxParams)
1181+
try:
1182+
payload = await self._request("POST", "/sandbox", data=data)
1183+
detail = SandboxDetail(**payload)
1184+
if normalized.secrets and not detail.id.strip():
1185+
raise ValueError("The API accepted the create but returned no sandbox ID")
1186+
return detail
1187+
except Exception as error:
1188+
# A create that may have reached the API may be running its
1189+
# startup command with the secrets, so it must not look retryable.
1190+
if normalized.secrets and request_may_have_arrived(error):
1191+
raise sandbox_create_outcome_unknown_error(error)
1192+
raise
11401193

11411194
async def _request(
11421195
self,

0 commit comments

Comments
 (0)