Skip to content

Add secrets management and secret delivery to sandboxes - #139

Merged
AHarmlessPyro merged 5 commits into
mainfrom
sandbox-secrets
Oct 12, 2026
Merged

AHarmlessPyro merged 5 commits into
mainfrom
sandbox-secrets

Conversation

@AHarmlessPyro

@AHarmlessPyro AHarmlessPyro commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Adds sandbox secrets to the SDK, following the hx vm secrets CLI (metlo-labs/browserctrl#1519) and reconciled against the current server and CLI on master. It needs the server's secrets stack deployed, and 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()
  • ID or name: methods take a secret's ID or its exact name.
    • A key in the standard 8-4-4-4-12 UUID layout is tried as an ID first, then as a name after a 404, as the CLI does. Other errors from the ID lookup are raised as they are.
    • Any other key goes straight to the name search. 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.
    • Envelopes were checked against vm-service's own decryption code.
    • Uses cryptography, which is a new dependency (>=41.0.0). Most of the poetry.lock diff is that package and its dependencies, locked per Python version.
  • Scopes: creating a secret needs secrets.manage. Updating, rotating or deleting existing secrets through the SDK needs both secrets.read and secrets.manage, because the SDK resolves metadata and encryption context, even for known IDs.
  • Signed-in users: when the SDK uses an hx auth login session instead of an API key, only team owners and admins can use /secrets. Other members get SECRETS_ADMIN_REQUIRED.
  • Checked locally, before any request:
    • values: str or bytes, used byte-for-byte, 1 byte to 32 KiB, UTF-8, no NUL
    • secret names, using the server's pattern
    • versions: 1 to 2^31-1, or "latest"
    • list page >= 1 and limit 1 to 100
  • create with a value checks the value, then fetches and verifies the key, before creating the secret. 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)]
  • sandboxes.create: secrets go to the image's startup command, so they work for image launches only. Snapshot launches with secrets are rejected locally.
    • An image without a startup command gets SECRET_STARTUP_UNSUPPORTED; pass the secrets to exec or processes.start instead.
    • If the create times out or its response can't be read, it raises a non-retryable secret_process_outcome_unknown: a sandbox may already be running its startup command with the secrets. The CLI does the same.
  • Process starts with secrets go through POST /api/sandbox/:id/processes.
    • Only the fields that route accepts are sent. timeout_sec becomes timeoutMs, and a positive timeout_sec takes precedence over timeout_ms, as in ordinary command execution.
    • use_shell, env names that are already set by env, and invalid env names are rejected locally.
    • Accepts process.id or processId in the start response.
  • Waiting:
    • exec closes stdin, ignoring a 409 from a command that already exited.
    • Secret-started handles and exec wait through the runtime in chunks below the runtime request timeout, and keep waiting after a 408.
    • wait() and result() have no local limit by default. A positive wait timeout limits the local wait, leaves the command running, and lets the handle be waited on again.
    • The async client enforces that limit exactly. The sync client checks it between requests and caps each request's HTTP timeouts at the time left, so a slow response or a token refresh can overrun it. The README says so.
    • Existing reattached-handle behavior is unchanged.
  • Failures:
    • A failure after the start raises secret_process_failed with {processId, sandboxId}.
    • These raise secret_process_outcome_unknown, which is not retryable because the command may be running:
      • a start that timed out
      • a start whose response can't be read or decoded, including malformed HTTP error bodies
      • a start response with no process ID
      • the server's own 502 SECRET_PROCESS_OUTCOME_UNKNOWN, whose body stays in details
  • SandboxDetail / Sandbox / SandboxSnapshotSummary expose contains_secrets.

Errors (all control requests)

  • Retries: the server's retryable flag overrides the status-code default.
    • Without a flag, codes that retrying can't fix are not retryable. For example, the 503 SECRETS_NOT_CONFIGURED is no longer retried.
    • Runtime errors are unchanged, since vm-service's proxy marks its own gateway errors non-retryable.
  • Error codes: 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, …).
  • Messages add (sandbox <id>) and (<env>: secret <id>, version <n>) when the body includes them, as the CLI does.
  • SECRETS_FEATURE_DISABLED: the 403 /secrets returns when the team lacks the feature. metlo-labs/browserctrl#1549 adds the same code to process starts, creates and snapshot restores.

Testing

  • Full local non-E2E suite: 795 passed, including the mypy/Pyright request-contract tests.
  • New tests/test_secrets.py covers:
    • wire contracts and name/ID resolution (ID, UUID-shaped name after a 404, a 500 not falling back, names with hyphens elsewhere)
    • an encryption round trip decrypted with a generated 3072-bit key, key ID and policy refusals, and the value, name and version rules
    • create ordering and the partial create
    • sandbox create serialization, the snapshot rejection, and the unknown-outcome create
    • sync and async secret process starts and exec (payload, 408 loop, 409 stdin), and the unknown-outcome classification
    • handle wait limits and reuse, short request budgets, and timeout precedence
    • error code, message 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 mypy 1.14.1 and pyright from the Poetry environment. The typecheck fixtures cover the new calls.
  • ruff check is clean. CI exercises Python 3.8 through 3.14.
  • Not run against a live API.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NhbFMc29f5JN5JxCciajJV


Note

High Risk
Introduces client-side crypto, secret injection into sandbox processes, and non-retryable ambiguous-outcome errors; mistakes could leak credentials into environments or cause unsafe retries after partial API success.

Overview
Adds client.secrets on sync and async clients to create, version, list, update, and delete team secrets. Values are encrypted locally (JWE via new cryptography dependency) before upload; the API never returns plaintext.

Sandboxes can receive secrets at image create (secrets on startup) and in sandbox.exec / sandbox.processes.start, which routes secret-bearing starts through the control API instead of the runtime SSE path. Process handles expose resolved secret versions and use chunked wait() with stricter failure semantics (secret_process_outcome_unknown, secret_process_failed). Models add contains_secrets on sandboxes/snapshots and validation for secret references.

Control-plane error parsing now honors server retryable flags, maps machine codes from error fields, and enriches messages with sandbox/secret context. README documents limits, scopes, and operational caveats.

Reviewed by Cursor Bugbot for commit 64cfebf. Bugbot is set up for automated code reviews on this repo. Configure here.

AHarmlessPyro and others added 2 commits October 11, 2026 03:19
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
@AHarmlessPyro
AHarmlessPyro marked this pull request as ready for review October 12, 2026 02:43
AHarmlessPyro and others added 3 commits October 11, 2026 22:07
- 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>
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>
@AHarmlessPyro
AHarmlessPyro added this pull request to the merge queue Oct 12, 2026
@AHarmlessPyro
AHarmlessPyro removed this pull request from the merge queue due to a manual request Oct 12, 2026
@AHarmlessPyro
AHarmlessPyro merged commit df69ca0 into main Oct 12, 2026
13 checks passed
@AHarmlessPyro
AHarmlessPyro deleted the sandbox-secrets branch October 12, 2026 13:56
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.

2 participants