Skip to content

Add sandbox secrets - #129

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, mirroring the hx vm secrets CLI in metlo-labs/browserctrl#1519. Only API-key routes are used. This needs the server's secrets stack, which ends in that PR.

client.secrets

await client.secrets.create({ name: "provider-key", description: "…", value });
await client.secrets.addVersion("provider-key", nextValue);
await client.secrets.list({ search: "provider", page: 1, limit: 20 });
await client.secrets.get("provider-key");           // metadata and versions, never the value
await client.secrets.update("provider-key", { description: "…" });
await client.secrets.deleteVersion("provider-key", 1);
await client.secrets.delete("provider-key");
await client.secrets.getPublicKey();
  • Name or ID: methods take a secret's ID or its exact name. A UUID-shaped argument is tried as an ID first, then as a name after a 404 (names may look like UUIDs). Any other error from the ID lookup is reported as is. A lookup by ID doesn't fetch the secret a second time.
  • Scopes: SDK updates, version additions, and deletions require secrets.read alongside secrets.manage, even for known IDs, because the SDK resolves metadata and encryption context. Creation needs only secrets.manage.
  • Values: strings or bytes, used byte-for-byte. The SDK checks the platform rules locally: 1 to 32,768 bytes, UTF-8, no NUL.
  • Encryption happens on the client. Each value becomes a compact JWE (RSA-OAEP-256 + A256GCM) bound to the secret's encryptionContext, and the API only receives the envelope.
    • The key must be at least 3072-bit RSA with exponent 65537.
    • Its RFC 7638 thumbprint must match kid, or the SDK refuses to encrypt.
    • Uses Node's built-in crypto, with no new dependencies.
  • create with a value checks the value and the key before creating the secret, so a bad value or an unusable key never leaves an empty secret behind. If the upload fails after the create, the error has code secret_value_not_stored, carries the secret in details, and says to use addVersion. Retrying create would fail with "name taken".

Delivering secrets

await sandbox.exec("node app.js", { secrets: { API_KEY: "provider-key", DB_URL: { name: "db-url", version: 2 } } });
const proc = await sandbox.processes.start("node worker.js", { secrets: { API_KEY: "provider-key" } });
proc.secrets; // [{ env, name, secretId, version }]
await client.sandboxes.create({ imageName: "my-image", secrets: { API_KEY: "provider-key" } });
  • Starting: processes.start/exec with secrets call POST /api/sandbox/:id/processes, so the sandbox service decrypts the values. They send only the fields that endpoint accepts: timeoutSec becomes timeoutMs, and runAs is passed through.
  • 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 request timeout, including sub-second client budgets. An expired receiver wait (408) means the command is still running. wait() / result() have no default local deadline; an explicit wait timeout bounds the whole local wait, closes its active request, and leaves the handle usable. Existing reattached-handle behavior is preserved. Output comes from retained output, so maxOutputBytes does not apply.
  • Cancellation: an already-aborted signal prevents the start. Signals also cancel the active control request, stdin close, and result wait. Cancellation after a start may have been accepted reports an unknown outcome; cancellation after a known start retains the process ID.
  • Execution timeouts: positive timeoutSec takes precedence over timeoutMs, matching ordinary process starts.
  • Unknown outcomes: a start that timed out or returned an unreadable or undecodable response (including malformed HTTP error bodies) gets secret_process_outcome_unknown with retryable: false, because the command may be running. So does a start response without a process ID. Connection failures and API errors pass through unchanged. A failure after the start gives secret_process_failed, with the processId in details.
  • Local checks: environment variable names, secret names, versions, the 32-secret limit, and a variable set by both env and secrets.
  • Sandbox create: secrets go to the image's startup command. They're allowed only for image launches, both in the type and at runtime.
  • containsSecrets has been added to sandboxes and snapshots.

Control API errors (all requests)

  • The server's retryable flag in an error body overrides the status-based default. For example, an ambiguous secret process start is a 502 with retryable: false.
  • A machine code sent as error next to a message (for example API_KEY_SCOPE_DENIED) becomes error.code. The full body, including requiredScopes, sandboxId, causeCode, exitCode and the secret details, stays in error.details as before.

Testing

  • Package build, source/test type checking, lint, and the complete local suite pass (711 tests).
  • Real HTTP regression tests cover repeated 408 waits, total local deadlines and handle reuse, short request budgets, cancellation and socket cleanup, timeout alias precedence, and malformed/interrupted error responses.
  • Independent review verified concurrent waits, abort-listener cleanup, and unchanged reattached-handle behavior.
  • New unit tests:
    • Envelope round-trip, decrypted with the private key.
    • Bytes are sealed exactly as given.
    • Mismatched kid, 2048-bit keys, the wrong exponent and the wrong algorithm are refused.
    • Value rules.
    • Request order for create, and a partial create.
    • Resolution: by ID, a UUID-shaped name after a 404, a 500 with no fallback, and an exact name match.
    • Sandbox create: how secrets serialize, and snapshot rejection.
    • Process start and exec through the control API, including the 408 wait loop, the stdin 409, the outcome-unknown cases and the failure after start.
    • Server retryable and the code in error.
  • Not yet run against a live API.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NhbFMc29f5JN5JxCciajJV


Note

High Risk
Introduces secret storage encryption, delivery into sandbox processes, and subtle non-retry error semantics where duplicate starts could leak credentials or leave orphan workloads.

Overview
Adds client.secrets for team secret lifecycle (create, versions, list/get metadata, delete) with client-side JWE encryption to the platform public key so plaintext values never hit the API.

Sandboxes can receive secrets as env vars via secrets on sandboxes.create (image startup only) and on processes.exec / processes.start, which route starts through the control API instead of the runtime SSE path. Handles expose delivered versions on handle.secrets; sandboxes/snapshots gain containsSecrets.

Secret starts change wait/exec semantics: output comes from retained runtime waits (chunked, no maxOutputBytes), with explicit handling for non-retryable secret_process_outcome_unknown when creates/starts may have succeeded anyway. Control-layer errors gain ResponseDecodingError, server retryable overrides, and richer messages for sandbox/secret context.

README documents usage, limits, and scopes; unit and HTTP integration tests cover envelopes, resolution, and recovery paths.

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

@AHarmlessPyro
AHarmlessPyro marked this pull request as ready for review October 12, 2026 02:43
- A sandbox create with secrets whose outcome is unknown throws 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 is not
  retryable.
- Send secret process timeouts as whole milliseconds within the server's
  24-hour limit.
- containsSecrets is always a boolean on sandboxes and snapshots.
- Check secret names, versions, list bounds and gvisor-cpu with secrets
  locally.
- Document SECRETS_FEATURE_DISABLED, SECRETS_NOT_CONFIGURED and
  SECRET_STARTUP_UNSUPPORTED.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 357b0d4. Configure here.

Comment thread src/types/sandbox.ts Outdated
@AHarmlessPyro
AHarmlessPyro merged commit eec9a15 into main Oct 12, 2026
3 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