Skip to content

Add secrets management and secret delivery to sandboxes - #139

Draft
AHarmlessPyro wants to merge 1 commit into
mainfrom
sandbox-secrets
Draft

AHarmlessPyro wants to merge 1 commit into
mainfrom
sandbox-secrets

Conversation

@AHarmlessPyro

Copy link
Copy Markdown
Contributor

Adds sandbox secrets to the SDK, mirroring the hx vm secrets CLI in metlo-labs/browserctrl#1519. It covers only the routes an API key can reach, and needs that server stack (#1513–#1518) deployed with the secrets feature enabled for the team.

client.secrets (sync and async)

client.secrets.create({"name": "provider-key", "description": "d", "value": b"..."})
client.secrets.add_version("provider-key", "rotated")           # next version
client.secrets.get("provider-key")                              # metadata + versions, never the value
client.secrets.list({"search": "provider", "page": 1, "limit": 20})
client.secrets.update("provider-key", {"description": "rotated monthly"})
client.secrets.delete_version("provider-key", 1)
client.secrets.delete("provider-key")
client.secrets.get_public_key()
  • Methods take a secret's ID or exact name. A UUID-shaped key is tried as an ID first and, after a 404, as a name, as the CLI does. Other errors from the ID lookup are raised as they are. get by ID makes one request.
  • Encryption happens client-side: a compact JWE (RSA-OAEP-256 + A256GCM) bound to the secret's encryptionContext. The API only receives the envelope.
    • The key must be at least 3072-bit RSA with exponent 65537, and its RFC 7638 thumbprint must match kid.
    • Uses cryptography, which is a new dependency (>=41.0.0).
  • Values are str or bytes, used byte-for-byte: 1 byte to 32 KiB, UTF-8, no NUL. These rules are checked locally.
  • create with a value checks the value and fetches and verifies the key before creating the secret, so a bad value or key leaves nothing behind.
    • If the upload fails after the create, it raises secret_value_not_stored. The error names the secret and points to add_version, because retrying create would fail with "name taken".

Using secrets

client.sandboxes.create({"image_name": "node", "secrets": {"API_KEY": "provider-key"}})
sandbox.exec("node app.js", secrets={"API_KEY": {"name": "provider-key", "version": 2}})
handle = sandbox.processes.start("node worker.js", secrets={"API_KEY": "provider-key"})
handle.secrets  # [SandboxResolvedSecret(env, name, secret_id, version)]
  • create: secrets is for image launches only. Snapshot launches with secrets are rejected locally.
  • Process starts with secrets go through POST /api/sandbox/:id/processes.
    • Only the fields that route accepts are sent. timeout_sec becomes timeoutMs.
    • Overlapping env and secrets names are rejected locally.
  • exec with secrets then closes stdin, ignoring a 409 from a command that already exited. It waits through the runtime in chunks, and treats a 408 as still running.
  • Failures:
    • A failure after the start raises secret_process_failed with {processId, sandboxId}.
    • A start that timed out or whose response couldn't be read raises secret_process_outcome_unknown. So does a start response with no process ID. The command may be running, so neither is marked retryable.
  • SandboxDetail / Sandbox / SandboxSnapshotSummary expose contains_secrets.

Errors (all control requests)

  • The server's retryable flag overrides the status-code default. Runtime errors are unchanged, since vm-service's proxy marks its own gateway errors non-retryable.
  • A machine code sent as error next to a message (e.g. API_KEY_SCOPE_DENIED) becomes HyperbrowserError.code. details still carries the full body (requiredScopes, sandboxId, causeCode, …).

Testing

  • poetry run pytest tests --ignore=tests/sandbox/e2e: 713 passed. New tests/test_secrets.py covers:
    • wire contracts and name/ID resolution (ID, UUID-shaped name after a 404, a 500 not falling back)
    • an encryption round trip decrypted with a generated 3072-bit key, key ID and policy refusals, and value rules
    • create ordering and the partial create
    • sandbox create serialization and the snapshot rejection
    • sync and async secret process starts and exec (payload, 408 loop, 409 stdin), and the unknown-outcome classification
    • error code and retryable parsing in both the control request path and the transport
  • tests/test_typing_contract.py and tests/test_typed_request_surface.py pass with CI's mypy 1.14.1 and pyright 1.1.411. The typecheck fixtures cover the new calls.
  • ruff check is clean. Pyright on hyperbrowser/ reports no new errors over main.
  • New tests plus the sandbox wire contract pass on Python 3.8.
  • Not run against a live API.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NhbFMc29f5JN5JxCciajJV

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant