diff --git a/README.md b/README.md index 2b733d8..5799494 100644 --- a/README.md +++ b/README.md @@ -375,6 +375,76 @@ await sandbox.stop(); await client.volumes.delete(sameVolume.id); ``` +Store secrets and deliver them to sandbox processes as environment variables: + +```typescript +// The value is encrypted locally to the platform key; the API only receives +// the envelope, and values are never returned. +await client.secrets.create({ + name: "provider-key", + description: "Provider API key", + value: process.env.PROVIDER_KEY!, +}); +await client.secrets.addVersion("provider-key", newValue); // next version +const secrets = await client.secrets.list({ search: "provider" }); +const detail = await client.secrets.get("provider-key"); // metadata and versions + +const sandbox = await client.sandboxes.create({ imageName: "node" }); +const result = await sandbox.exec("node app.js", { + secrets: { API_KEY: "provider-key", DB_URL: { name: "db-url", version: 2 } }, +}); + +// Long-running processes report the versions they received. +const worker = await sandbox.processes.start("node worker.js", { + secrets: { API_KEY: "provider-key" }, +}); +console.log(worker.secrets); + +// Secrets for the image's startup command (image launches only). +await client.sandboxes.create({ imageName: "my-image", secrets: { API_KEY: "provider-key" } }); + +await client.secrets.deleteVersion("provider-key", 1); +await client.secrets.delete("provider-key"); +``` + +- Secret methods take a secret's ID or exact name. Values are strings or bytes, + used byte-for-byte: 1 to 32,768 bytes of UTF-8 without NUL. +- A secret is delivered only to the process it is passed to, but anything + running as the same user (or root) in the sandbox can read that process's + environment. The sandbox and its later snapshots are marked + `containsSecrets`. +- Deleting a secret does not revoke a value already delivered to a sandbox. +- Limits: 100 secrets per team, 10 stored versions per secret, 32 secrets per + process or sandbox. The API key needs the `secrets.read`, `secrets.manage` + or `secrets.use` scope for reading, managing or delivering secrets. + SDK updates, version additions, and deletions also require `secrets.read` + alongside `secrets.manage`: the SDK resolves the existing secret before + changing it, even when given an ID. Encryption also needs its encryption + context. Creating a new secret needs only `secrets.manage`. +- A process start with secrets is never retried. If it times out, its + response is lost, or the API reports that the sandbox host did not confirm + the start (`SECRET_PROCESS_OUTCOME_UNKNOWN`), the error code is + `secret_process_outcome_unknown` and the API's response, if any, is in + `details`: check the sandbox's processes before starting it again. +- The same applies to `sandboxes.create` with `secrets`: if the create result + can't be confirmed, the error code is `secret_process_outcome_unknown`, since + a sandbox may already be running its startup command with the secrets. List + sandboxes before creating it again. +- Startup secrets need an image with a startup command and a Firecracker + sandbox. Otherwise the create fails with `SECRET_STARTUP_UNSUPPORTED`; create + the sandbox without `secrets` and pass them to `processes.exec` or + `processes.start` instead. +- Secrets routes return 403 `SECRETS_FEATURE_DISABLED` when the team doesn't + have secrets enabled, and 503 `SECRETS_NOT_CONFIGURED` (not retryable) when + the server has no secrets encryption key. +- A command timeout (`timeoutSec` or `timeoutMs`) for a process started with + secrets is sent in whole milliseconds and can be at most 24 hours. +- Secret-started handles support `wait()` and `result()` without a local + deadline. An explicit wait timeout limits the whole local wait, leaving the + command running; the same handle can be awaited again. Runtime waits use + bounded requests below the client's request timeout. Output still comes from + the receiver's retained output, so `maxOutputBytes` does not apply. + List sandboxes with time-range and search filters: ```typescript diff --git a/src/client.ts b/src/client.ts index 7e29b31..c83c2dd 100644 --- a/src/client.ts +++ b/src/client.ts @@ -18,6 +18,7 @@ import { JevComputerUseService } from "./services/agents/jev-computer-use"; import { WebService } from "./services/web"; import { SandboxesService } from "./services/sandboxes"; import { VolumesService } from "./services/volumes"; +import { SecretsService } from "./services/secrets"; import { HyperbrowserError } from "./error"; export { HyperbrowserError } from "./error"; @@ -45,6 +46,7 @@ export class HyperbrowserClient { public readonly computerAction: ComputerActionService; public readonly sandboxes: SandboxesService; public readonly volumes: VolumesService; + public readonly secrets: SecretsService; constructor(config: HyperbrowserConfig = {}) { const apiKey = config.apiKey || process.env["HYPERBROWSER_API_KEY"]; @@ -69,6 +71,7 @@ export class HyperbrowserClient { this.computerAction = new ComputerActionService(apiKey, baseUrl, timeout); this.sandboxes = new SandboxesService(apiKey, baseUrl, timeout, runtimeProxyOverride); this.volumes = new VolumesService(apiKey, baseUrl, timeout); + this.secrets = new SecretsService(apiKey, baseUrl, timeout); this.agents = { browserUse: new BrowserUseService(apiKey, baseUrl, timeout), diff --git a/src/error.ts b/src/error.ts index 910e9b7..74ecbb4 100644 --- a/src/error.ts +++ b/src/error.ts @@ -52,3 +52,6 @@ export class HyperbrowserError extends Error { this.path = normalized.path; } } + +/** @internal The response body could not be read or decoded as JSON. */ +export class ResponseDecodingError extends HyperbrowserError {} diff --git a/src/sandbox/process.ts b/src/sandbox/process.ts index 246802b..ec086ac 100644 --- a/src/sandbox/process.ts +++ b/src/sandbox/process.ts @@ -1,4 +1,5 @@ -import { HyperbrowserError } from "../error"; +import { performance } from "perf_hooks"; +import { HyperbrowserError, ResponseDecodingError } from "../error"; import { RuntimeSSEEvent, RuntimeSSEStream, RuntimeTransport } from "./base"; import { DEFAULT_MAX_PROCESS_OUTPUT_BYTES, @@ -7,9 +8,15 @@ import { RawProcessResult, validateOutputLimit, } from "./process-output"; +import { + assertNoEnvSecretOverlap, + serializeSecretReferences, + WireSecretReference, +} from "./secret-references"; import { SandboxExecParams, SandboxExecOptions, + SandboxResolvedSecret, SandboxProcessListParams, SandboxProcessListResponse, SandboxProcessResult, @@ -33,7 +40,7 @@ interface ProcessListWireResponse { next_cursor?: string; } -interface RawProcessSummary extends Partial { +export interface RawProcessSummary extends Partial { id: string; status: SandboxProcessSummary["status"]; command: string; @@ -45,6 +52,71 @@ interface RawProcessSummary extends Partial { completed_at?: number | null; } +/** The body of a control API process start with secrets. */ +export interface SecretProcessStartPayload { + command: string; + cwd?: string; + env?: Record; + timeoutMs?: number; + runAs?: string; + secrets: Record; +} + +export interface SecretProcessStartResponse { + /** The sandbox agent's summary, passed through unchanged. */ + process?: RawProcessSummary; + secrets?: SandboxResolvedSecret[]; +} + +/** @internal Starts processes with secrets through the control API. */ +export interface SecretProcessStarter { + sandboxId: string; + /** Each runtime wait stays under the runtime request timeout. */ + waitChunkMs: number; + start( + payload: SecretProcessStartPayload, + signal?: AbortSignal + ): Promise; +} + +interface SecretProcessWaitContext { + chunkMs: number; + signal?: AbortSignal; +} + +/** Errors from connecting at all; anything else may have reached the API. */ +const CONNECT_ERROR_CODES = new Set(["ECONNREFUSED", "ENOTFOUND", "EAI_AGAIN", "EHOSTUNREACH"]); + +/** The control API's code for a secret process start it could not confirm. */ +const SERVER_OUTCOME_UNKNOWN_CODE = "SECRET_PROCESS_OUTCOME_UNKNOWN"; + +/** The control API's limit on a secret process's `timeoutMs`. */ +const MAX_SECRET_PROCESS_TIMEOUT_MS = 86_400_000; + +/** + * @internal Whether a failed control API request may still have reached the + * API: it timed out or its response couldn't be read. API errors and failures + * to connect are excluded. + */ +export const requestMayHaveArrived = (error: unknown): boolean => { + if (error instanceof ResponseDecodingError) { + return true; + } + if (!(error instanceof HyperbrowserError)) { + return true; + } + // Pre-aborted calls are rejected before sending. Cancellation here may + // interrupt an error body after the API has already accepted the start. + if (error.code === "request_aborted") { + return true; + } + if (error.statusCode !== undefined && error.statusCode >= 400) { + return false; + } + const code = (error.cause as { code?: string } | undefined)?.code; + return !(code && CONNECT_ERROR_CODES.has(code)); +}; + const DEFAULT_PROCESS_KILL_WAIT_MS = 5_000; const SHELL_SAFE_TOKEN_PATTERN = /^[A-Za-z0-9_@%+=:,./-]+$/; @@ -160,6 +232,49 @@ const localWaitTimeoutMs = (params: SandboxProcessWaitParams): number | undefine return undefined; }; +/** + * The command timeout for a secret process start, as the whole milliseconds + * the control API accepts. Rounded, but a positive timeout stays at least 1ms + * rather than becoming 0 (no timeout). + */ +const secretProcessTimeoutMs = (params: SandboxExecParams): number | undefined => { + for (const name of ["timeoutSec", "timeoutMs"] as const) { + const value = params[name]; + if ( + value !== undefined && + (typeof value !== "number" || !Number.isFinite(value) || value < 0) + ) { + throw new HyperbrowserError(`${name} must be a non-negative number`); + } + } + // Match the receiver: positive seconds take precedence over milliseconds. + const timeoutMs = localWaitTimeoutMs(params); + if (timeoutMs === undefined) { + return undefined; + } + const wholeMs = Math.max(1, Math.round(timeoutMs)); + if (wholeMs > MAX_SECRET_PROCESS_TIMEOUT_MS) { + throw new HyperbrowserError( + `A process started with secrets can run for at most ${MAX_SECRET_PROCESS_TIMEOUT_MS / 1000} seconds` + ); + } + return wholeMs; +}; + +const waitTimeoutError = (): HyperbrowserError => + new HyperbrowserError("Timed out waiting for command output", { + code: "wait_timeout", + service: "runtime", + retryable: false, + }); + +const requestAbortedError = (): HyperbrowserError => + new HyperbrowserError("Request canceled", { + code: "request_aborted", + retryable: false, + service: "runtime", + }); + const buildProcessPayload = (input: SandboxExecParams) => ({ command: input.command, cwd: input.cwd, @@ -208,10 +323,17 @@ export class SandboxProcessHandle { private closeStream: (() => void) | null = null; private readonly changed = new ChangeSignal(); + /** The secret versions delivered to a process started with `secrets`. */ + public readonly secrets?: SandboxResolvedSecret[]; + constructor( private readonly transport: RuntimeTransport, - private summary: SandboxProcessSummary - ) {} + private summary: SandboxProcessSummary, + secrets?: SandboxResolvedSecret[], + private readonly secretWait?: SecretProcessWaitContext + ) { + this.secrets = secrets; + } /** @internal Collect output from the start-stream that created this process. */ attachCollector(stream: RuntimeSSEStream, maxOutputBytes: number): void { @@ -326,6 +448,73 @@ export class SandboxProcessHandle { } return this.collectedResult(); } + if (this.secretWait) { + return this.waitWithSecrets(params); + } + return this.waitRetained(params); + } + + /** Secret starts retain output on the receiver, so poll until the local deadline. */ + private async waitWithSecrets(params: SandboxProcessWaitParams): Promise { + const context = this.secretWait as SecretProcessWaitContext; + const timeoutMs = localWaitTimeoutMs(params); + const deadline = timeoutMs === undefined ? undefined : performance.now() + timeoutMs; + for (;;) { + if (context.signal?.aborted) throw requestAbortedError(); + const remaining = deadline === undefined ? undefined : deadline - performance.now(); + if (remaining !== undefined && remaining <= 0) throw waitTimeoutError(); + + const controller = new AbortController(); + let deadlineExpired = false; + let cancelWait!: (error: HyperbrowserError) => void; + const canceled = new Promise((_, reject) => { + cancelWait = reject; + }); + const abort = () => { + controller.abort(); + cancelWait(requestAbortedError()); + }; + context.signal?.addEventListener("abort", abort, { once: true }); + // Node timers cannot represent intervals above 2^31-1 ms. Long local + // deadlines are checked again after every bounded request instead. + const timer = + remaining !== undefined && remaining <= 2_147_483_647 + ? setTimeout(() => { + deadlineExpired = true; + controller.abort(); + cancelWait(waitTimeoutError()); + }, remaining) + : undefined; + try { + const result = await Promise.race([ + this.waitRetained( + { timeoutMs: Math.max(1, Math.floor(Math.min(context.chunkMs, remaining ?? Infinity))) }, + controller.signal + ), + canceled, + ]); + if (context.signal?.aborted) throw requestAbortedError(); + if (deadlineExpired || (deadline !== undefined && performance.now() >= deadline)) { + throw waitTimeoutError(); + } + return result; + } catch (error) { + if (context.signal?.aborted) throw requestAbortedError(); + if (deadlineExpired || (deadline !== undefined && performance.now() >= deadline)) { + throw waitTimeoutError(); + } + if (!(error instanceof HyperbrowserError) || error.statusCode !== 408) throw error; + } finally { + clearTimeout(timer); + context.signal?.removeEventListener("abort", abort); + } + } + } + + private async waitRetained( + params: SandboxProcessWaitParams, + signal?: AbortSignal + ): Promise { const response = await this.transport.requestJSON( `/sandbox/processes/${this.id}/wait`, { @@ -337,6 +526,7 @@ export class SandboxProcessHandle { headers: { "content-type": "application/json", }, + signal, } ); const result = normalizeProcessResult(response.result); @@ -462,7 +652,10 @@ export class SandboxProcessHandle { } export class SandboxProcessesApi { - constructor(private readonly transport: RuntimeTransport) {} + constructor( + private readonly transport: RuntimeTransport, + private readonly secretStarter?: SecretProcessStarter + ) {} /** Run a command to completion, collecting its full output from process start. */ async exec(command: string, options?: SandboxExecOptions): Promise; @@ -471,8 +664,11 @@ export class SandboxProcessesApi { input: string | SandboxExecParams, options?: SandboxExecOptions ): Promise { - const handle = - typeof input === "string" ? await this.start(input, options) : await this.start(input); + const params = normalizeExecParams(input, options); + if (params.secrets && Object.keys(params.secrets).length > 0) { + return this.execWithSecrets(params); + } + const handle = await this.start(params); try { return await handle.wait(); } finally { @@ -492,6 +688,9 @@ export class SandboxProcessesApi { options?: SandboxExecOptions ): Promise { const params = normalizeExecParams(input, options); + if (params.secrets && Object.keys(params.secrets).length > 0) { + return this.startWithSecrets(params); + } const maxOutputBytes = params.maxOutputBytes ?? DEFAULT_MAX_PROCESS_OUTPUT_BYTES; validateOutputLimit(maxOutputBytes); const stream = await this.transport.openSSE("/sandbox/processes", undefined, { @@ -521,6 +720,130 @@ export class SandboxProcessesApi { return handle; } + /** + * Start through the control API so the sandbox service decrypts the + * secrets. Output is then read through the runtime's retained output. + */ + private async startWithSecrets(params: SandboxExecParams): Promise { + if (params.signal?.aborted) throw requestAbortedError(); + const starter = this.secretStarter; + if (!starter) { + throw new HyperbrowserError("Starting a process with secrets requires a sandbox handle"); + } + const secrets = serializeSecretReferences(params.secrets) as Record< + string, + WireSecretReference + >; + assertNoEnvSecretOverlap(params.env, secrets); + const timeoutMs = secretProcessTimeoutMs(params); + + let response: SecretProcessStartResponse; + try { + response = await starter.start( + { + command: params.command, + cwd: params.cwd, + env: params.env, + timeoutMs, + runAs: params.runAs, + secrets, + }, + params.signal + ); + } catch (error) { + if (error instanceof HyperbrowserError && error.code === SERVER_OUTCOME_UNKNOWN_CODE) { + // The API reached the sandbox host but could not confirm the start. + throw new HyperbrowserError(error.message.replace(/^\[Hyperbrowser\]: /, ""), { + statusCode: error.statusCode, + code: "secret_process_outcome_unknown", + requestId: error.requestId, + retryable: false, + service: "control", + details: { + ...(typeof error.details === "object" ? error.details : undefined), + sandboxId: starter.sandboxId, + }, + cause: error, + method: error.method, + path: error.path, + }); + } + if (!requestMayHaveArrived(error)) { + throw error; + } + throw new HyperbrowserError( + `The process start may have reached the API, but no result came back; the command may be running, so check the sandbox's processes before retrying`, + { + code: "secret_process_outcome_unknown", + retryable: false, + service: "control", + details: { sandboxId: starter.sandboxId }, + cause: error, + } + ); + } + const process = response?.process; + const processId = process?.id; + if (!process || typeof processId !== "string" || !processId.trim()) { + // The start succeeded, so the command may be running with the secrets. + throw new HyperbrowserError( + "The API accepted the process start but returned no process ID; the command may be running, so check the sandbox's processes before retrying", + { + code: "secret_process_outcome_unknown", + retryable: false, + service: "control", + details: { sandboxId: starter.sandboxId }, + } + ); + } + return new SandboxProcessHandle( + this.transport, + normalizeProcessSummary({ ...process, id: processId.trim() }), + response.secrets ?? [], + { chunkMs: starter.waitChunkMs, signal: params.signal } + ); + } + + /** + * Like one-shot exec: stdin is closed, then the command's final result is + * awaited. The command's own timeout is enforced in the sandbox, so an + * expired wait (408) only means it is still running. + */ + private async execWithSecrets(params: SandboxExecParams): Promise { + const handle = await this.startWithSecrets(params); + const starter = this.secretStarter as SecretProcessStarter; + try { + try { + await this.transport.requestJSON(`/sandbox/processes/${handle.id}/stdin`, { + method: "POST", + body: JSON.stringify({ eof: true }), + headers: { "content-type": "application/json" }, + signal: params.signal, + }); + } catch (error) { + // 409: the command already exited; its result is still reported. + if (!(error instanceof HyperbrowserError) || error.statusCode !== 409) { + throw error; + } + } + return await handle.wait(); + } catch (error) { + // Running exec again would start a second process with the secrets. + throw new HyperbrowserError( + `Process ${handle.id} started, but waiting for its result failed: ${ + error instanceof Error ? error.message.replace(/^\[Hyperbrowser\]: /, "") : String(error) + }`, + { + code: "secret_process_failed", + retryable: false, + service: "runtime", + details: { sandboxId: starter.sandboxId, processId: handle.id }, + cause: error, + } + ); + } + } + async get(processId: string): Promise { const response = await this.transport.requestJSON( `/sandbox/processes/${processId}` diff --git a/src/sandbox/secret-references.ts b/src/sandbox/secret-references.ts new file mode 100644 index 0000000..35ab4ea --- /dev/null +++ b/src/sandbox/secret-references.ts @@ -0,0 +1,74 @@ +import { HyperbrowserError } from "../error"; +import { SandboxSecrets } from "../types/sandbox"; + +const ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/; +export const SECRET_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/; +/** The largest version number the API stores. */ +export const MAX_SECRET_VERSION = 2_147_483_647; +const MAX_SECRETS = 32; + +export interface WireSecretReference { + name: string; + version?: number | "latest"; +} + +/** + * Check secret references locally and convert them to the API's + * `{ name, version? }` shape. Returns undefined when there are none. + */ +export const serializeSecretReferences = ( + secrets: SandboxSecrets | undefined +): Record | undefined => { + if (secrets === undefined || secrets === null) { + return undefined; + } + if (typeof secrets !== "object" || Array.isArray(secrets)) { + throw new HyperbrowserError("secrets must map environment variable names to secrets"); + } + const entries = Object.entries(secrets); + if (entries.length === 0) { + return undefined; + } + if (entries.length > MAX_SECRETS) { + throw new HyperbrowserError(`At most ${MAX_SECRETS} secrets can be delivered at once`); + } + const wire: Record = {}; + for (const [env, reference] of entries) { + if (!ENV_NAME_PATTERN.test(env) || env === "__proto__") { + throw new HyperbrowserError(`Invalid environment variable name for a secret: ${env}`); + } + const { name, version } = + typeof reference === "string" ? { name: reference, version: undefined } : (reference ?? {}); + if (typeof name !== "string" || !SECRET_NAME_PATTERN.test(name)) { + throw new HyperbrowserError(`Invalid secret name for ${env}: ${String(name)}`); + } + if ( + version !== undefined && + version !== "latest" && + (!Number.isSafeInteger(version) || + (version as number) < 1 || + (version as number) > MAX_SECRET_VERSION) + ) { + throw new HyperbrowserError( + `Secret version for ${env} must be an integer from 1 to ${MAX_SECRET_VERSION} or "latest"` + ); + } + wire[env] = version === undefined ? { name } : { name, version }; + } + return wire; +}; + +/** An environment variable must come from either `env` or `secrets`, not both. */ +export const assertNoEnvSecretOverlap = ( + env: Record | undefined, + secrets: Record +): void => { + const overlap = Object.keys(env ?? {}).filter((name) => + Object.prototype.hasOwnProperty.call(secrets, name) + ); + if (overlap.length > 0) { + throw new HyperbrowserError( + `Environment variables set by both env and secrets: ${overlap.sort().join(", ")}` + ); + } +}; diff --git a/src/secrets-envelope.ts b/src/secrets-envelope.ts new file mode 100644 index 0000000..e4307a4 --- /dev/null +++ b/src/secrets-envelope.ts @@ -0,0 +1,138 @@ +import crypto from "crypto"; +import { HyperbrowserError } from "./error"; +import { SecretsPublicKey, SecretValue } from "./types/secret"; + +/** The largest secret value the platform accepts. */ +export const MAX_SECRET_VALUE_BYTES = 32 * 1024; + +const MIN_SECRETS_KEY_BITS = 3072; +const SECRETS_KEY_EXPONENT = 65537; + +const invalid = (message: string): HyperbrowserError => + new HyperbrowserError(message, { code: "invalid_secret_value", retryable: false }); + +const base64Url = (data: Buffer | Uint8Array): string => Buffer.from(data).toString("base64url"); + +/** + * Apply the platform's value rules locally, so a bad value is reported before + * anything is created rather than when a sandbox starts. + */ +export const secretValueBytes = (value: SecretValue): Buffer => { + if (typeof value !== "string" && !(value instanceof Uint8Array)) { + throw invalid("Secret value must be a string or bytes"); + } + const bytes = Buffer.from(value); + if (bytes.length === 0) { + throw invalid("Secret value is empty"); + } + if (bytes.length > MAX_SECRET_VALUE_BYTES) { + throw invalid(`Secret value is more than ${MAX_SECRET_VALUE_BYTES} bytes`); + } + if (bytes.includes(0)) { + throw invalid("Secret value must not contain NUL bytes"); + } + try { + new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch { + throw invalid("Secret value must be valid UTF-8"); + } + return bytes; +}; + +const minimalBytes = (value: Buffer): Buffer => { + let start = 0; + while (start < value.length - 1 && value[start] === 0) start += 1; + return value.subarray(start); +}; + +/** RFC 7638 JWK SHA-256 thumbprint of an RSA key. */ +const rsaThumbprint = (n: Buffer, e: Buffer): string => + base64Url( + crypto + .createHash("sha256") + .update(`{"e":"${base64Url(e)}","kty":"RSA","n":"${base64Url(n)}"}`) + .digest() + ); + +/** Encrypts values to a verified secrets public key. */ +export class SecretsSealer { + private constructor( + private readonly kid: string, + private readonly publicKey: crypto.KeyObject + ) {} + + /** + * Check that `key` is a supported RSA key whose ID matches the key itself. A + * mismatch means a corrupted response or a misconfigured server; where the + * key comes from rests on TLS to the API. + */ + static fromPublicKey(key: SecretsPublicKey): SecretsSealer { + const unsupported = (message: string) => + new HyperbrowserError(message, { code: "invalid_secrets_public_key", retryable: false }); + if (key?.alg !== "RSA-OAEP-256" || key.enc !== "A256GCM" || key.jwk?.kty !== "RSA") { + throw unsupported(`Unsupported secrets public key (${key?.alg}/${key?.enc})`); + } + const n = minimalBytes(Buffer.from(key.jwk.n ?? "", "base64url")); + const e = minimalBytes(Buffer.from(key.jwk.e ?? "", "base64url")); + if (n.length === 0 || n[0] === 0 || e.length === 0 || e.length > 4) { + throw unsupported("Secrets public key is malformed"); + } + // The same policy the server and vm-service enforce for their keys. + const bits = n.length * 8 - Math.clz32(n[0]) + 24; + if (bits < MIN_SECRETS_KEY_BITS || e.readUIntBE(0, e.length) !== SECRETS_KEY_EXPONENT) { + throw unsupported( + `Secrets public key must be at least ${MIN_SECRETS_KEY_BITS}-bit RSA with exponent ${SECRETS_KEY_EXPONENT}` + ); + } + if (rsaThumbprint(n, e) !== key.kid) { + throw unsupported("Secrets public key does not match its key ID"); + } + const publicKey = crypto.createPublicKey({ + key: { kty: "RSA", n: base64Url(n), e: base64Url(e) }, + format: "jwk", + }); + return new SecretsSealer(key.kid, publicKey); + } + + /** + * Encrypt `value` as a compact JWE (RSA-OAEP-256 + A256GCM) bound to a + * secret's encryption context. + */ + seal(encryptionContext: string, value: SecretValue): string { + const plaintext = secretValueBytes(value); + const protectedHeader = base64Url( + Buffer.from( + JSON.stringify({ + alg: "RSA-OAEP-256", + enc: "A256GCM", + kid: this.kid, + ctx: encryptionContext, + }) + ) + ); + const cek = crypto.randomBytes(32); + const iv = crypto.randomBytes(12); + try { + const encryptedKey = crypto.publicEncrypt( + { + key: this.publicKey, + padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, + oaepHash: "sha256", + }, + cek + ); + const cipher = crypto.createCipheriv("aes-256-gcm", cek, iv); + cipher.setAAD(Buffer.from(protectedHeader, "ascii")); + const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]); + return [ + protectedHeader, + base64Url(encryptedKey), + base64Url(iv), + base64Url(ciphertext), + base64Url(cipher.getAuthTag()), + ].join("."); + } finally { + cek.fill(0); + } + } +} diff --git a/src/services/base.ts b/src/services/base.ts index 0c59370..6fc2223 100644 --- a/src/services/base.ts +++ b/src/services/base.ts @@ -1,5 +1,10 @@ import fetch, { HeadersInit, RequestInit, Response } from "node-fetch"; -import { errorRequestPath, HyperbrowserError, networkErrorMessage } from "../error"; +import { + errorRequestPath, + HyperbrowserError, + networkErrorMessage, + ResponseDecodingError, +} from "../error"; import { getRetryDelayMs, isRetryableNetworkError, @@ -12,6 +17,34 @@ const getRequestId = (response: Response): string | undefined => { return response.headers.get("x-request-id") || response.headers.get("request-id") || undefined; }; +/** The API's machine codes, such as SECRET_NOT_FOUND. */ +const SERVER_ERROR_CODE_PATTERN = /^[A-Z][A-Z0-9]*(_[A-Z0-9]+)+$/; + +/** Codes whose retryable status default (such as 503) would be wrong. */ +const NON_RETRYABLE_ERROR_CODES = new Set(["SECRETS_NOT_CONFIGURED"]); + +/** The sandbox and secret an error is about, when the API names them. */ +const errorMessageContext = (errorData: { + sandboxId?: unknown; + details?: { env?: unknown; secretId?: unknown; version?: unknown } | null; +}): string => { + let context = ""; + // A failed create may leave a sandbox the caller has to stop. + if (typeof errorData.sandboxId === "string" && errorData.sandboxId.trim()) { + context += ` (sandbox ${errorData.sandboxId.trim()})`; + } + // Which of several secrets could not be used. + const secret = errorData.details; + if (secret && typeof secret.env === "string" && secret.env.trim()) { + const parts = [ + typeof secret.secretId === "string" ? `secret ${secret.secretId}` : undefined, + typeof secret.version === "number" ? `version ${secret.version}` : undefined, + ].filter(Boolean); + context += ` (${secret.env.trim()}${parts.length > 0 ? `: ${parts.join(", ")}` : ""})`; + } + return context; +}; + export class BaseService { constructor( protected readonly apiKey: string, @@ -92,23 +125,50 @@ export class BaseService { let errorMessage: string; let errorDetails: unknown; let errorCode: string | undefined; + let decodingFailed = false; + let decodingCause: unknown; + let retryable = RETRYABLE_STATUS_CODES.has(response.status); try { const errorData = await response.json(); errorDetails = errorData; errorCode = typeof errorData?.code === "string" ? errorData.code : undefined; + // Some routes send the code as "error" next to a message, for + // example API_KEY_SCOPE_DENIED. + if ( + errorCode === undefined && + errorData?.message && + typeof errorData.error === "string" && + SERVER_ERROR_CODE_PATTERN.test(errorData.error) + ) { + errorCode = errorData.error; + } + // The server knows better when it says so: an ambiguous process + // start with secrets is a 502 that must not be retried blindly. + if (typeof errorData?.retryable === "boolean") { + retryable = errorData.retryable; + } else if (errorCode !== undefined && NON_RETRYABLE_ERROR_CODES.has(errorCode)) { + retryable = false; + } + const message = errorData.message || errorData.error; errorMessage = - errorData.message || errorData.error || `HTTP error! status: ${response.status}`; + typeof message === "string" && message + ? `${message}${errorMessageContext(errorData)}` + : message || `HTTP error! status: ${response.status}`; } catch (error) { if (init?.signal?.aborted) throw error; + decodingFailed = true; + decodingCause = error; errorMessage = `HTTP error! status: ${response.status}`; } - throw new HyperbrowserError(errorMessage, { + const ErrorType = decodingFailed ? ResponseDecodingError : HyperbrowserError; + throw new ErrorType(errorMessage, { statusCode: response.status, code: errorCode, requestId: getRequestId(response), - retryable: RETRYABLE_STATUS_CODES.has(response.status), + retryable, service: "control", details: errorDetails, + cause: decodingCause, method: init?.method ?? "GET", path: errorRequestPath(path), }); diff --git a/src/services/sandboxes.ts b/src/services/sandboxes.ts index b211db5..8c0273c 100644 --- a/src/services/sandboxes.ts +++ b/src/services/sandboxes.ts @@ -2,7 +2,14 @@ import { HyperbrowserError } from "../error"; import { SandboxFilesApi } from "../sandbox/files"; import { RuntimeConnection, RuntimeTransport } from "../sandbox/base"; import { runtimeSessionIdFromPath } from "../sandbox/runtime-path"; -import { SandboxProcessHandle, SandboxProcessesApi } from "../sandbox/process"; +import { + requestMayHaveArrived, + SandboxProcessHandle, + SandboxProcessesApi, + SecretProcessStartPayload, + SecretProcessStartResponse, +} from "../sandbox/process"; +import { serializeSecretReferences } from "../sandbox/secret-references"; import { SandboxTerminalApi } from "../sandbox/terminal"; import { buildDockerImageFromDockerfile, @@ -68,6 +75,8 @@ import { optionalInteger, optionalNumber } from "./normalize"; import { BaseService } from "./base"; const RUNTIME_SESSION_REFRESH_BUFFER_MS = 60_000; +/** Runtime waits for secret processes stay well under the request timeout. */ +const SECRET_PROCESS_WAIT_CHUNK_MS = 20_000; type WireSandbox = Omit & { vcpus?: number | null; @@ -100,6 +109,7 @@ const normalizeSandbox = (sandbox: WireSandbox): Sandbox => { timeoutMinutes: optionalInteger(rest.timeoutMinutes), creditsUsed: optionalNumber(rest.creditsUsed) ?? null, exposedPorts: rest.exposedPorts ?? [], + containsSecrets: rest.containsSecrets ?? false, network: rest.network ? { ...rest.network, allowOut: rest.network.allowOut ?? [], denyOut: rest.network.denyOut ?? [] } : rest.network, }; }; @@ -144,6 +154,10 @@ const serializeCreateSandboxParams = (params: CreateSandboxParams): Record service.startSecretProcess(this.id, payload, signal), + }); this.files = new SandboxFilesApi( this.transport, () => this.resolveRuntimeSocketConnectionInfo(), @@ -1354,6 +1382,34 @@ export class SandboxesService extends BaseService { } } + /** + * Start a process with secrets through the control API. Never retried: a + * failed start may still have run the command. + */ + async startSecretProcess( + id: string, + payload: SecretProcessStartPayload, + signal?: AbortSignal + ): Promise { + try { + return await this.request( + `/sandbox/${encodeURIComponent(id)}/processes`, + { + method: "POST", + body: JSON.stringify(payload), + signal, + } + ); + } catch (error) { + if (error instanceof HyperbrowserError) { + throw error; + } + throw new HyperbrowserError(`Failed to start a process with secrets in sandbox ${id}`, { + cause: error, + }); + } + } + async updateNetwork( id: string, policy: SandboxNetworkPolicyPatch @@ -1385,14 +1441,42 @@ export class SandboxesService extends BaseService { } } + /** + * With secrets, a create whose result is unknown is never retried: the + * sandbox may exist and be running its startup command with the secrets. + */ private async createDetail(params: CreateSandboxParams): Promise { + const body = serializeCreateSandboxParams(params); try { const detail = await this.request("/sandbox", { method: "POST", - body: JSON.stringify(serializeCreateSandboxParams(params)), + body: JSON.stringify(body), }); + if ( + body.secrets !== undefined && + (typeof detail?.id !== "string" || !detail.id.trim()) + ) { + throw new Error("The API accepted the create but returned no sandbox ID"); + } return normalizeSandboxDetail(detail); } catch (error) { + if (body.secrets !== undefined && requestMayHaveArrived(error)) { + throw new HyperbrowserError( + `could not confirm the create result: ${ + error instanceof Error + ? error.message.replace(/^\[Hyperbrowser\]: /, "") + : String(error) + }; a sandbox may have been created with the secrets, so list sandboxes before retrying`, + { + code: "secret_process_outcome_unknown", + retryable: false, + service: "control", + cause: error, + method: "POST", + path: "/sandbox", + } + ); + } if (error instanceof HyperbrowserError) { throw error; } diff --git a/src/services/secrets.ts b/src/services/secrets.ts new file mode 100644 index 0000000..3d2553f --- /dev/null +++ b/src/services/secrets.ts @@ -0,0 +1,232 @@ +import { HyperbrowserError } from "../error"; +import { MAX_SECRET_VERSION, SECRET_NAME_PATTERN } from "../sandbox/secret-references"; +import { secretValueBytes, SecretsSealer } from "../secrets-envelope"; +import { + CreateSecretParams, + Secret, + SecretDeleteResult, + SecretDetail, + SecretListParams, + SecretListResponse, + SecretsPublicKey, + SecretValue, + SecretVersion, + SecretVersionDeleteResult, + UpdateSecretParams, +} from "../types/secret"; +import { BaseService } from "./base"; + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +/** The most secrets one list page can hold. */ +const MAX_LIST_LIMIT = 100; +/** Teams hold at most 100 secrets, so one page of search results covers every match. */ +const NAME_LOOKUP_LIMIT = MAX_LIST_LIMIT; + +const secretPath = (id: string): string => `/secrets/${encodeURIComponent(id)}`; + +const rethrow = (error: unknown, message: string): never => { + if (error instanceof HyperbrowserError) { + throw error; + } + throw new HyperbrowserError(message, { cause: error }); +}; + +/** + * Secrets are encrypted locally to the platform's public key; the API only + * ever receives envelopes, and values are never returned. + * + * Methods that take `secret` accept its ID or its exact name. A UUID-shaped + * argument is tried as an ID first and then as a name, since names may look + * like UUIDs. + */ +export class SecretsService extends BaseService { + /** The key secret values are encrypted to. */ + async getPublicKey(): Promise { + try { + return await this.request("/secrets/public-key"); + } catch (error) { + return rethrow(error, "Failed to get the secrets public key"); + } + } + + async list(params: SecretListParams = {}): Promise { + const { page, limit } = params; + if (page !== undefined && (!Number.isSafeInteger(page) || page < 1)) { + throw new HyperbrowserError("page must be a positive integer"); + } + if ( + limit !== undefined && + (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_LIST_LIMIT) + ) { + throw new HyperbrowserError(`limit must be an integer from 1 to ${MAX_LIST_LIMIT}`); + } + try { + return await this.request("/secrets", undefined, { + search: params.search, + page: params.page, + limit: params.limit, + }); + } catch (error) { + return rethrow(error, "Failed to list secrets"); + } + } + + /** + * Create a secret, and store its first version when `value` is given. The + * value and the platform key are checked before the secret is created, so a + * bad value never leaves an empty secret behind. + */ + async create(params: CreateSecretParams): Promise { + const { name, description, value } = params ?? ({} as CreateSecretParams); + if (typeof name !== "string" || !SECRET_NAME_PATTERN.test(name)) { + throw new HyperbrowserError( + "Secret name must be 1-128 letters, digits, '_' or '-', starting with a letter or digit" + ); + } + const sealer = value === undefined ? undefined : await this.prepareSealer(value); + + let secret: Secret; + try { + secret = await this.request("/secrets", { + method: "POST", + body: JSON.stringify({ name, description }), + }); + } catch (error) { + return rethrow(error, `Failed to create secret ${name}`); + } + if (sealer === undefined) { + return secret; + } + + try { + const version = await this.uploadVersion(secret, sealer, value as SecretValue); + return { ...secret, latestVersion: version.version }; + } catch (error) { + // Retrying create would fail with "name taken"; the value has to be + // stored on the secret that now exists. + throw new HyperbrowserError( + `Secret ${secret.name} (${secret.id}) was created, but storing its value failed: ${ + error instanceof Error ? error.message.replace(/^\[Hyperbrowser\]: /, "") : String(error) + }. Store it with secrets.addVersion(${JSON.stringify(secret.name)}, value).`, + { + code: "secret_value_not_stored", + retryable: false, + service: "control", + details: { secret }, + cause: error, + } + ); + } + } + + /** Metadata and versions; never the value. */ + async get(secret: string): Promise { + try { + return await this.resolveDetail(secret); + } catch (error) { + return rethrow(error, `Failed to get secret ${secret}`); + } + } + + async update(secret: string, params: UpdateSecretParams): Promise { + try { + const { id } = await this.resolve(secret); + return await this.request(secretPath(id), { + method: "PATCH", + body: JSON.stringify({ description: params.description }), + }); + } catch (error) { + return rethrow(error, `Failed to update secret ${secret}`); + } + } + + /** Encrypt `value` locally and store it as the secret's next version. */ + async addVersion(secret: string, value: SecretValue): Promise { + const sealer = await this.prepareSealer(value); + try { + return await this.uploadVersion(await this.resolve(secret), sealer, value); + } catch (error) { + return rethrow(error, `Failed to add a version to secret ${secret}`); + } + } + + /** + * Permanently delete a secret and its versions. Values already delivered to + * sandboxes are not revoked. + */ + async delete(secret: string): Promise { + try { + const { id } = await this.resolve(secret); + return await this.request(secretPath(id), { method: "DELETE" }); + } catch (error) { + return rethrow(error, `Failed to delete secret ${secret}`); + } + } + + /** Delete one stored version. The latest version cannot be deleted. */ + async deleteVersion(secret: string, version: number): Promise { + if (!Number.isSafeInteger(version) || version < 1 || version > MAX_SECRET_VERSION) { + throw new HyperbrowserError(`version must be an integer from 1 to ${MAX_SECRET_VERSION}`); + } + try { + const { id } = await this.resolve(secret); + return await this.request( + `${secretPath(id)}/versions/${version}`, + { + method: "DELETE", + } + ); + } catch (error) { + return rethrow(error, `Failed to delete version ${version} of secret ${secret}`); + } + } + + private async prepareSealer(value: SecretValue): Promise { + secretValueBytes(value); + return SecretsSealer.fromPublicKey(await this.getPublicKey()); + } + + private uploadVersion(secret: Secret, sealer: SecretsSealer, value: SecretValue) { + return this.request(`${secretPath(secret.id)}/versions`, { + method: "POST", + body: JSON.stringify({ envelope: sealer.seal(secret.encryptionContext, value) }), + }); + } + + private async resolve(secret: string): Promise { + return (await this.lookup(secret)).secret; + } + + private async resolveDetail(secret: string): Promise { + const found = await this.lookup(secret); + return found.detail ?? (await this.request(secretPath(found.secret.id))); + } + + /** Found by ID, the result already includes the versions. */ + private async lookup(key: string): Promise<{ secret: Secret; detail?: SecretDetail }> { + if (typeof key !== "string" || !key.trim()) { + throw new HyperbrowserError("A secret ID or name is required"); + } + key = key.trim(); + if (UUID_PATTERN.test(key)) { + try { + const detail = await this.request(secretPath(key)); + return { secret: detail, detail }; + } catch (error) { + if (!(error instanceof HyperbrowserError) || error.statusCode !== 404) { + throw error; + } + } + } + const { secrets } = await this.list({ search: key, limit: NAME_LOOKUP_LIMIT }); + const secret = secrets.find((candidate) => candidate.name === key); + if (!secret) { + throw new HyperbrowserError(`Secret ${JSON.stringify(key)} not found`, { + statusCode: 404, + code: "SECRET_NOT_FOUND", + service: "control", + }); + } + return { secret }; + } +} diff --git a/src/types/index.ts b/src/types/index.ts index 1f0e4d7..6e5ea11 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -175,6 +175,9 @@ export { SandboxDetail, SandboxVolumeMountType, SandboxVolumeMount, + SandboxSecretReference, + SandboxSecrets, + SandboxResolvedSecret, SandboxListParams, SandboxListResponse, SandboxImageSource, @@ -265,6 +268,20 @@ export { VolumeListResponse, VolumeDeleteResult, } from "./volume"; +export { + SecretValue, + SecretsPublicKey, + SecretsPublicKeyJwk, + Secret, + SecretVersion, + SecretDetail, + CreateSecretParams, + UpdateSecretParams, + SecretListParams, + SecretListResponse, + SecretDeleteResult, + SecretVersionDeleteResult, +} from "./secret"; export { CreateProfileParams, ForkProfileParams, diff --git a/src/types/sandbox.contract.d.ts b/src/types/sandbox.contract.d.ts index 15411cb..31ffbe1 100644 --- a/src/types/sandbox.contract.d.ts +++ b/src/types/sandbox.contract.d.ts @@ -42,6 +42,18 @@ type SnapshotMountsAllowed = { } extends CreateSandboxParams ? true : false; +type ImageSecretsAllowed = { + imageName: "image"; + secrets: { API_KEY: "provider-key"; DB_URL: { name: "db-url"; version: 2 } }; +} extends CreateSandboxParams + ? true + : false; +type SnapshotSecretsAllowed = { + snapshotName: "snapshot"; + secrets: { API_KEY: "provider-key" }; +} extends CreateSandboxParams + ? true + : false; type _NoSandboxNameSource = Assert; type _SnapshotSourceAllowed = Assert; @@ -54,3 +66,5 @@ type _SnapshotCpuDisallowed = Assert; type _SnapshotDiskDisallowed = Assert; type _SnapshotMountsAllowed = Assert; +type _ImageSecretsAllowed = Assert; +type _SnapshotSecretsDisallowed = Assert; diff --git a/src/types/sandbox.ts b/src/types/sandbox.ts index 7b20be9..412b660 100644 --- a/src/types/sandbox.ts +++ b/src/types/sandbox.ts @@ -65,6 +65,11 @@ export interface Sandbox { network?: SandboxNetworkPolicy | null; runtime: SandboxRuntimeTarget; exposedPorts: SandboxExposeResult[]; + /** + * Secrets were delivered into the sandbox, or it was restored from a + * snapshot that may hold them. + */ + containsSecrets?: boolean; } export interface SandboxDetail extends Sandbox { @@ -74,6 +79,28 @@ export interface SandboxDetail extends Sandbox { export type SandboxVolumeMountType = "rw" | "ro"; +/** + * A stored secret to deliver as an environment variable: its name, or + * `{ name, version }` to pin a version. The latest version is used otherwise. + */ +export type SandboxSecretReference = + | string + | { + name: string; + version?: number | "latest"; + }; + +/** Environment variable name to secret, at most 32 per process or sandbox. */ +export type SandboxSecrets = Record; + +/** The secret version an environment variable received. */ +export interface SandboxResolvedSecret { + env: string; + name: string; + secretId: string; + version: number; +} + export interface SandboxVolumeMount { id: string; type?: SandboxVolumeMountType; @@ -101,6 +128,7 @@ export type CreateSandboxParams = cpu?: never; memoryMiB?: never; diskMiB?: never; + secrets?: never; }) | (SandboxCreateCommonParams & { snapshotName?: never; @@ -110,6 +138,12 @@ export type CreateSandboxParams = cpu?: number; memoryMiB?: number; diskMiB?: number; + /** + * Secrets for the image's startup command only; requires the API key's + * `secrets.use` scope and is not supported with `runtimeClass: + * "gvisor-cpu"`. The sandbox is marked as containing secrets. + */ + secrets?: SandboxSecrets; }); export interface SandboxListParams { @@ -179,6 +213,8 @@ export interface SandboxSnapshotSummary { compatibilityTag?: string | null; metadata: Record; uploaded: boolean; + /** The snapshot's memory may hold secrets. */ + containsSecrets?: boolean; createdAt: string; updatedAt: string; } @@ -485,6 +521,17 @@ export interface SandboxExecParams { timeoutMs?: number; timeoutSec?: number; runAs?: string; + /** + * Stored secrets to deliver as environment variables of this process only. + * The process is started through the control API, which has the sandbox + * service decrypt them; requires the API key's `secrets.use` scope. Anything + * running as the same user (or root) in the sandbox can read the process's + * environment, and the sandbox is marked as containing secrets. + * + * Output is read back from the process's retained output rather than the + * start stream, so `maxOutputBytes` does not apply. + */ + secrets?: SandboxSecrets; /** @deprecated Ignored for process APIs. */ useShell?: boolean; } diff --git a/src/types/secret.ts b/src/types/secret.ts new file mode 100644 index 0000000..d15e6d8 --- /dev/null +++ b/src/types/secret.ts @@ -0,0 +1,78 @@ +/** A secret value: a UTF-8 string or its raw bytes, used byte-for-byte. */ +export type SecretValue = string | Uint8Array; + +export interface SecretsPublicKeyJwk { + kty: string; + n: string; + e: string; +} + +/** The platform key that secret values are encrypted to before upload. */ +export interface SecretsPublicKey { + kid: string; + alg: string; + enc: string; + jwk: SecretsPublicKeyJwk; +} + +export interface Secret { + id: string; + name: string; + description: string; + /** Bound into every envelope for this secret; the SDK uses it when encrypting. */ + encryptionContext: string; + /** Null until a value is stored. */ + latestVersion: number | null; + createdAt: string; + updatedAt: string; +} + +export interface SecretVersion { + version: number; + kid: string; + createdAt: string; +} + +export interface SecretDetail extends Secret { + versions: SecretVersion[]; +} + +export interface CreateSecretParams { + /** 1-128 letters, digits, '_' or '-', starting with a letter or digit. */ + name: string; + description?: string; + /** + * The first version's value. It is validated and the platform key is checked + * before the secret is created, then encrypted locally and uploaded. + */ + value?: SecretValue; +} + +export interface UpdateSecretParams { + description: string; +} + +export interface SecretListParams { + search?: string; + page?: number; + /** At most 100. */ + limit?: number; +} + +export interface SecretListResponse { + secrets: Secret[]; + totalCount: number; + page: number; + perPage: number; +} + +export interface SecretDeleteResult { + deleted: boolean; + id: string; + name: string; +} + +export interface SecretVersionDeleteResult { + deleted: boolean; + version: number; +} diff --git a/tests/integration/secret-process-recovery.test.ts b/tests/integration/secret-process-recovery.test.ts new file mode 100644 index 0000000..520e9e3 --- /dev/null +++ b/tests/integration/secret-process-recovery.test.ts @@ -0,0 +1,424 @@ +import { IncomingMessage, ServerResponse } from "http"; +import { afterEach, describe, expect, test } from "vitest"; +import { HyperbrowserError } from "../../src/error"; +import { SandboxesService } from "../../src/services/sandboxes"; +import { localHTTP } from "../helpers/local-http"; + +const sandboxId = "sbx_123"; +const processId = "proc_1"; +const startPath = `/api/sandbox/${sandboxId}/processes`; +const waitPath = `/sandbox/processes/${processId}/wait`; +const rawProcess = { + id: processId, + status: "running", + command: "node app.js", + cwd: "/app", + pid: 42, + started_at: 10, +}; +const resolvedSecrets = [ + { env: "API_KEY", name: "provider-key", secretId: "secret_1", version: 2 }, +]; +const rawResult = { + id: processId, + status: "exited", + exit_code: 0, + stdout: "ok\n", + stderr: "", + started_at: 10, + completed_at: 11, +}; +const command = { + command: "node app.js", + cwd: "/app", + secrets: { API_KEY: { name: "provider-key", version: 2 } }, +}; +type Call = { + method: string; + path: string; + body: Record | undefined; + headers: IncomingMessage["headers"]; +}; +type Handler = ( + req: IncomingMessage, + res: ServerResponse, + call: Call +) => boolean | Promise; + +const cleanup: Array<() => Promise> = []; +afterEach(async () => { + for (const close of cleanup.splice(0)) await close(); +}); + +function deferred() { + let resolve!: () => void; + const promise = new Promise((done) => (resolve = done)); + return { promise, resolve }; +} + +function within(pending: Promise, ms = 1_000): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`No response within ${ms}ms`)), ms); + pending.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (error: unknown) => { + clearTimeout(timer); + reject(error); + } + ); + }); +} + +function json(res: ServerResponse, body: unknown, status = 200) { + res.writeHead(status, { "content-type": "application/json" }); + res.end(JSON.stringify(body)); +} + +async function setup(handle?: Handler, timeout = 30_000) { + const calls: Call[] = []; + let runtimeUrl = ""; + const server = await localHTTP(async (req, res) => { + let text = ""; + for await (const chunk of req) text += chunk; + const call = { + method: req.method!, + path: req.url!, + body: text ? (JSON.parse(text) as Record) : undefined, + headers: req.headers, + }; + calls.push(call); + if (call.method === "GET" && call.path === `/api/sandbox/${sandboxId}`) { + json(res, { + id: sandboxId, + teamId: "test-team", + status: "active", + region: "us", + duration: 0, + createdAt: "now", + updatedAt: "now", + sessionUrl: "https://sandbox.example.test", + runtime: { transport: "regional_proxy", host: "local.test", baseUrl: runtimeUrl }, + exposedPorts: [], + containsSecrets: true, + token: "local-runtime-token", + tokenExpiresAt: null, + }); + } else if (handle && (await handle(req, res, call))) { + return; + } else if (call.method === "POST" && call.path === startPath) { + json(res, { process: rawProcess, secrets: resolvedSecrets }); + } else if (call.method === "GET" && call.path === `/sandbox/processes/${processId}`) { + json(res, { process: rawProcess }); + } else if (call.method === "POST" && call.path === `/sandbox/processes/${processId}/stdin`) { + json(res, { success: true }); + } else { + json(res, { message: `Unexpected ${call.method} ${call.path}` }, 404); + } + }); + cleanup.push(server.close); + runtimeUrl = server.url; + const service = new SandboxesService("local-control-key", server.url, timeout); + return { ...server, calls, sandbox: await service.get(sandboxId) }; +} + +describe("secret process recovery over HTTP", () => { + test.each(["wait", "result"] as const)( + "start().%s() continues bounded receiver waits through multiple 408 responses", + async (method) => { + const waits: Call[] = []; + const api = await setup((_req, res, call) => { + if (call.path !== waitPath) return false; + waits.push(call); + // Return receiver wait expirations immediately; no real 30-second sleep is needed. + if (waits.length <= 3) json(res, { message: "Process is still running" }, 408); + else json(res, { result: rawResult }); + return true; + }); + const handle = await api.sandbox.processes.start(command); + + await expect(handle[method]()).resolves.toMatchObject({ + id: processId, + exitCode: 0, + stdout: "ok\n", + }); + expect(handle.secrets).toEqual(resolvedSecrets); + expect(waits).toHaveLength(4); + for (const call of waits) { + expect(call.method).toBe("POST"); + expect(call.body?.timeoutMs).toBeGreaterThan(0); + expect(call.body?.timeoutMs).toBeLessThanOrEqual(20_000); + expect(call.body?.timeoutMs).toBeLessThan(30_000); + expect(call.body?.timeout_sec).toBeUndefined(); + } + const starts = api.calls.filter((call) => call.path === startPath); + expect(starts).toHaveLength(1); + expect(starts[0]).toMatchObject({ + method: "POST", + body: command, + headers: { "x-api-key": "local-control-key" }, + }); + } + ); + + test("an explicit wait deadline closes the response body and leaves the same handle usable", async () => { + const accepted = deferred(); + const closed = deferred(); + const waits: Call[] = []; + const api = await setup((req, res, call) => { + if (call.path !== waitPath) return false; + waits.push(call); + if (waits.length === 1) { + req.socket.once("close", closed.resolve); + res.writeHead(200, { "content-type": "application/json" }); + res.write('{"result":'); + accepted.resolve(); + } else json(res, { result: rawResult }); + return true; + }, 3_000); + const handle = await api.sandbox.processes.start(command); + const pending = handle.wait({ timeoutMs: 250 }).catch((error: unknown) => error); + await within(accepted.promise); + + expect(await within(pending)).toMatchObject({ + code: "wait_timeout", + retryable: false, + service: "runtime", + }); + await within(closed.promise); + expect(waits[0].body?.timeoutMs).toBeGreaterThan(0); + expect(waits[0].body?.timeoutMs).toBeLessThanOrEqual(250); + await expect(handle.wait()).resolves.toMatchObject({ id: processId, exitCode: 0 }); + expect(waits).toHaveLength(2); + expect(api.calls.filter((call) => call.path === startPath)).toHaveLength(1); + }); + + test("a reattached ordinary process still performs one receiver wait and returns its 408", async () => { + const waits: Call[] = []; + const api = await setup((_req, res, call) => { + if (call.path !== waitPath) return false; + waits.push(call); + json(res, { message: "Process is still running" }, 408); + return true; + }); + const handle = await api.sandbox.processes.get(processId); + + await expect(handle.wait()).rejects.toMatchObject({ statusCode: 408 }); + expect(waits).toHaveLength(1); + expect(waits[0].body).toEqual({}); + expect(api.calls.filter((call) => call.path === startPath)).toHaveLength(0); + }); + + test("a 200ms SDK request budget permits secret exec to span several 100ms waits", async () => { + const waits: Call[] = []; + const api = await setup((_req, res, call) => { + if (call.path !== waitPath) return false; + waits.push(call); + const count = waits.length; + const timer = setTimeout(() => { + if (count < 3) json(res, { message: "Process is still running" }, 408); + else json(res, { result: rawResult }); + }, 100); + res.once("close", () => clearTimeout(timer)); + return true; + }, 200); + const startedAt = Date.now(); + + await expect( + api.sandbox.processes.exec({ ...command, timeoutMs: 1_000 }) + ).resolves.toMatchObject({ id: processId, exitCode: 0 }); + expect(Date.now() - startedAt).toBeGreaterThanOrEqual(250); + expect(waits.map((call) => call.body)).toEqual([ + { timeoutMs: 100 }, + { timeoutMs: 100 }, + { timeoutMs: 100 }, + ]); + expect(api.calls.find((call) => call.path === startPath)?.body?.timeoutMs).toBe(1_000); + expect( + api.calls.find((call) => call.path === `/sandbox/processes/${processId}/stdin`)?.body + ).toEqual({ eof: true }); + expect(api.calls.filter((call) => call.path === startPath)).toHaveLength(1); + }); + + test("a disabled millisecond command timeout falls back to the positive seconds alias", async () => { + const api = await setup(); + + await api.sandbox.processes.start({ ...command, timeoutMs: 0, timeoutSec: 1 }); + + expect(api.calls.find((call) => call.path === startPath)?.body).toEqual({ + ...command, + timeoutMs: 1_000, + }); + }); +}); + +describe("secret process cancellation over HTTP", () => { + test.each(["start", "exec"] as const)( + "a preaborted %s signal sends no process POST", + async (method) => { + const api = await setup(); + const controller = new AbortController(); + controller.abort(); + + await expect( + api.sandbox.processes[method]({ ...command, signal: controller.signal }) + ).rejects.toMatchObject({ code: "request_aborted", retryable: false }); + expect(api.calls.filter((call) => call.method === "POST")).toHaveLength(0); + } + ); + + test.each([ + { name: "before any response", responseStatus: undefined }, + { name: "after partial 502 headers and body", responseStatus: 502 }, + ])( + "aborting an accepted control start $name reports an unknown outcome without a retry", + async ({ responseStatus }) => { + const accepted = deferred(); + const closed = deferred(); + const api = await setup((req, res, call) => { + if (call.path !== startPath) return false; + req.socket.once("close", closed.resolve); + if (responseStatus === undefined) accepted.resolve(); + else { + res.writeHead(responseStatus, { "content-type": "application/json" }); + res.write('{"code":'); + // Allow node-fetch to consume the error headers before caller cancellation. + const timer = setTimeout(accepted.resolve, 50); + res.once("close", () => clearTimeout(timer)); + } + return true; + }, 3_000); + const controller = new AbortController(); + const pending = api.sandbox.processes + .start({ ...command, signal: controller.signal }) + .catch((error: unknown) => error); + await within(accepted.promise); + controller.abort(); + + const error = await within(pending); + expect(error).toMatchObject({ + code: "secret_process_outcome_unknown", + retryable: false, + details: { sandboxId }, + cause: { code: "request_aborted", retryable: false }, + }); + if (responseStatus !== undefined) + expect(error).toMatchObject({ cause: { statusCode: responseStatus } }); + await within(closed.promise); + expect(api.calls.filter((call) => call.path === startPath)).toHaveLength(1); + } + ); + + test("aborting an in-flight exec wait promptly closes its socket and retains process IDs", async () => { + const accepted = deferred(); + const closed = deferred(); + const api = await setup((req, res, call) => { + if (call.path !== waitPath) return false; + req.socket.once("close", closed.resolve); + res.writeHead(200, { "content-type": "application/json" }); + res.write('{"result":'); + accepted.resolve(); + return true; + }, 5_000); + const controller = new AbortController(); + const pending = api.sandbox.processes + .exec({ ...command, signal: controller.signal }) + .catch((error: unknown) => error); + await within(accepted.promise); + controller.abort(); + + expect(await within(pending)).toMatchObject({ + code: "secret_process_failed", + retryable: false, + service: "runtime", + details: { sandboxId, processId }, + cause: { code: "request_aborted", retryable: false }, + }); + await within(closed.promise); + expect(api.calls.filter((call) => call.path === startPath)).toHaveLength(1); + expect(api.calls.filter((call) => call.path === waitPath)).toHaveLength(1); + }); +}); + +describe("secret start error responses over HTTP", () => { + test.each(["malformed", "interrupted"] as const)( + "an unreadable %s 502 body reports an unknown outcome without a retry", + async (mode) => { + const api = await setup((req, res, call) => { + if (call.path !== startPath) return false; + res.writeHead(502, { + "content-type": "application/json", + "x-request-id": "secret-start-unreadable", + }); + if (mode === "malformed") res.end("Upstream failure"); + else { + res.write('{"code":'); + // Give node-fetch the headers and partial body before severing the response. + const timer = setTimeout(() => req.socket.destroy(), 50); + res.once("close", () => clearTimeout(timer)); + } + return true; + }); + + await expect(api.sandbox.processes.start(command)).rejects.toMatchObject({ + code: "secret_process_outcome_unknown", + retryable: false, + details: { sandboxId }, + cause: { statusCode: 502, requestId: "secret-start-unreadable" }, + }); + expect(api.calls.filter((call) => call.path === startPath)).toHaveLength(1); + } + ); + + test("the API's unconfirmed start is reported as an unknown outcome without a retry", async () => { + const body = { + code: "SECRET_PROCESS_OUTCOME_UNKNOWN", + message: "The sandbox host did not confirm the process start", + retryable: false, + }; + const api = await setup((_req, res, call) => { + if (call.path !== startPath) return false; + json(res, body, 502); + return true; + }); + const error = await api.sandbox.processes.start(command).catch((error: unknown) => error); + + expect(error).toBeInstanceOf(HyperbrowserError); + expect(error).toMatchObject({ + statusCode: 502, + code: "secret_process_outcome_unknown", + retryable: false, + service: "control", + details: { ...body, sandboxId }, + cause: { code: "SECRET_PROCESS_OUTCOME_UNKNOWN" }, + }); + expect(api.calls.filter((call) => call.path === startPath)).toHaveLength(1); + }); + + test.each([ + { status: 503, code: "SECRET_PROCESS_NOT_STARTED", retryable: true }, + { status: 500, code: "SECRET_DECRYPT_FAILED", retryable: false }, + ])("a valid $status API error preserves its code and retryable=$retryable", async (response) => { + const api = await setup((_req, res, call) => { + if (call.path !== startPath) return false; + json( + res, + { code: response.code, message: "Process start failed", retryable: response.retryable }, + response.status + ); + return true; + }); + const error = await api.sandbox.processes.start(command).catch((error: unknown) => error); + + expect(error).toBeInstanceOf(HyperbrowserError); + expect(error).toMatchObject({ + statusCode: response.status, + code: response.code, + retryable: response.retryable, + service: "control", + }); + expect(api.calls.filter((call) => call.path === startPath)).toHaveLength(1); + }); +}); diff --git a/tests/unit/list-contract.test.ts b/tests/unit/list-contract.test.ts index ce5aabe..8f1c8d2 100644 --- a/tests/unit/list-contract.test.ts +++ b/tests/unit/list-contract.test.ts @@ -39,6 +39,7 @@ describe("sandbox control list contract", () => { browserUrlExpiresAt: "2026-03-12T00:00:01Z", }, ], + containsSecrets: false, }, ], totalCount: 1, diff --git a/tests/unit/public-types-contract.test.ts b/tests/unit/public-types-contract.test.ts index 45ae9bf..edf750e 100644 --- a/tests/unit/public-types-contract.test.ts +++ b/tests/unit/public-types-contract.test.ts @@ -20,6 +20,7 @@ import type { SandboxImageListResponse, SandboxNetworkPolicy, SandboxSnapshotListResponse, + SandboxSnapshotSummary, SessionRegion, SessionStatus, VolumeListResponse, @@ -58,6 +59,11 @@ function actionConsumer(params: ComputerActionParams): string { } describe("public type compatibility", () => { + test("accepts sandbox and snapshot objects created before secrets metadata existed", () => { + expectTypeOf>().toExtend(); + expectTypeOf>().toExtend(); + }); + test("exports cursor-position requests and typed coordinates with optional pointer modifiers", () => { const request: CursorPositionActionParams = { action: ComputerAction.CURSOR_POSITION }; const response: ComputerActionResponseDataCursorPosition = { x: 10, y: 20 }; diff --git a/tests/unit/sandbox-secrets.test.ts b/tests/unit/sandbox-secrets.test.ts new file mode 100644 index 0000000..6c9c23c --- /dev/null +++ b/tests/unit/sandbox-secrets.test.ts @@ -0,0 +1,514 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { FetchError, Headers, Response } from "node-fetch"; + +const fetchMock = vi.fn(); +vi.mock("node-fetch", async () => { + const actual = await vi.importActual("node-fetch"); + return { ...actual, default: (...args: unknown[]) => fetchMock(...args) }; +}); + +import { HyperbrowserError } from "../../src/client"; +import type { RuntimeTransport } from "../../src/sandbox/base"; +import { SandboxProcessesApi, SecretProcessStarter } from "../../src/sandbox/process"; +import { BaseService } from "../../src/services/base"; +import { SandboxesService } from "../../src/services/sandboxes"; + +const wireDetail = { + id: "sbx_123", + status: "active", + region: "us", + runtime: { transport: "regional_proxy", host: "h", baseUrl: "https://runtime.example.com" }, + exposedPorts: [], + containsSecrets: true, + token: "tok", + tokenExpiresAt: null, +}; + +const rawProcess = { + id: "proc_1", + status: "running" as const, + command: "node app.js", + cwd: "/app", + pid: 42, + started_at: 10, +}; + +const resolved = [{ env: "API_KEY", name: "provider-key", secretId: "s1", version: 2 }]; + +const result = { + id: "proc_1", + status: "exited", + exit_code: 0, + stdout: "ok\n", + stderr: "", + started_at: 10, + completed_at: 11, +}; + +const starter = ( + start: SecretProcessStarter["start"] = vi.fn(async () => ({ + process: rawProcess, + secrets: resolved, + })) +) => ({ sandboxId: "sbx_123", waitChunkMs: 20_000, start: vi.fn(start) }); + +/** A runtime transport whose requestJSON answers from `routes` in order per path. */ +const runtime = (routes: Record>) => { + const requestJSON = vi.fn(async (path: string, _init?: { body?: string }) => { + const queue = routes[path]; + const next = queue?.shift(); + if (next === undefined) throw new Error(`unexpected runtime call ${path}`); + if (next instanceof Error) throw next; + return next; + }); + const openSSE = vi.fn(); + return { + requestJSON, + openSSE, + transport: { requestJSON, openSSE } as unknown as RuntimeTransport, + }; +}; + +describe("sandbox create with secrets", () => { + test("image launches send secret references in the API shape", async () => { + const service = new SandboxesService("test-key", "https://api.example.com", 30_000); + const requestSpy = vi.spyOn(service as any, "request").mockResolvedValue(wireDetail); + + const sandbox = await service.create({ + imageName: "node", + secrets: { API_KEY: "provider-key", DB_URL: { name: "db-url", version: 3 } }, + }); + + const body = JSON.parse((requestSpy.mock.calls[0][1] as any).body); + expect(body.secrets).toEqual({ + API_KEY: { name: "provider-key" }, + DB_URL: { name: "db-url", version: 3 }, + }); + expect(sandbox.toJSON().containsSecrets).toBe(true); + }); + + test("snapshot launches and bad references are rejected locally", async () => { + const service = new SandboxesService("test-key", "https://api.example.com", 30_000); + const requestSpy = vi.spyOn(service as any, "request").mockResolvedValue(wireDetail); + + await expect( + service.create({ snapshotName: "snap", secrets: { A: "x" } } as any) + ).rejects.toThrow(/only supported for image launches/); + await expect(service.create({ imageName: "node", secrets: { "1BAD": "x" } })).rejects.toThrow( + /environment variable name/ + ); + await expect( + service.create({ imageName: "node", secrets: { A: { name: "x", version: 0 } } }) + ).rejects.toThrow(/integer from 1 to 2147483647/); + await expect( + service.create({ imageName: "node", secrets: { A: { name: "x", version: 2 ** 31 } } }) + ).rejects.toThrow(/integer from 1 to 2147483647/); + await expect( + service.create({ imageName: "node", runtimeClass: "gvisor-cpu", secrets: { A: "x" } }) + ).rejects.toThrow(/not supported with runtimeClass gvisor-cpu/); + expect(requestSpy).not.toHaveBeenCalled(); + }); + + test("containsSecrets defaults to false when a response omits it", async () => { + const service = new SandboxesService("test-key", "https://api.example.com", 30_000); + const { containsSecrets: _omitted, ...withoutFlag } = wireDetail; + vi.spyOn(service as any, "request").mockResolvedValue(withoutFlag); + expect((await service.get("sbx_123")).toJSON().containsSecrets).toBe(false); + }); +}); + +describe("sandbox create with secrets and an unknown result", () => { + afterEach(() => fetchMock.mockReset()); + + const service = () => new SandboxesService("test-key", "https://api.example.com", 1_000); + const create = (secrets?: Record) => + service() + .create({ imageName: "node", ...(secrets ? { secrets } : {}) }) + .catch((error: unknown) => error) as Promise; + + test("a timed-out create is never retried and reports that a sandbox may exist", async () => { + fetchMock.mockRejectedValue( + new FetchError("network timeout at: /api/sandbox", "request-timeout") + ); + + const error = await create({ API_KEY: "provider-key" }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(error).toBeInstanceOf(HyperbrowserError); + expect(error).toMatchObject({ + code: "secret_process_outcome_unknown", + retryable: false, + service: "control", + cause: { retryable: true }, + }); + expect(error.message).toContain( + "could not confirm the create result: network timeout at: /api/sandbox; a sandbox may have been created with the secrets" + ); + expect(error.message).toContain("list sandboxes before retrying"); + }); + + test("an unreadable create response is an unknown outcome", async () => { + fetchMock.mockResolvedValueOnce(new Response("ok", { status: 200 })); + await expect(create({ API_KEY: "provider-key" })).resolves.toMatchObject({ + code: "secret_process_outcome_unknown", + retryable: false, + }); + fetchMock.mockResolvedValueOnce(new Response("Bad gateway", { status: 502 })); + await expect(create({ API_KEY: "provider-key" })).resolves.toMatchObject({ + code: "secret_process_outcome_unknown", + cause: { statusCode: 502 }, + }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + test("API errors, failed connections and creates without secrets pass through", async () => { + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ code: "SECRET_NOT_FOUND", message: "missing" }), { + status: 404, + headers: new Headers({ "content-type": "application/json" }), + }) + ); + await expect(create({ API_KEY: "provider-key" })).resolves.toMatchObject({ + code: "SECRET_NOT_FOUND", + statusCode: 404, + }); + + fetchMock.mockRejectedValueOnce( + new FetchError( + "connect ECONNREFUSED", + "system", + Object.assign(new Error("refused"), { + code: "ECONNREFUSED", + }) + ) + ); + await expect(create({ API_KEY: "provider-key" })).resolves.toMatchObject({ + code: undefined, + retryable: true, + }); + + fetchMock.mockRejectedValueOnce(new FetchError("network timeout", "request-timeout")); + await expect(create()).resolves.toMatchObject({ code: undefined, retryable: true }); + }); + + test.each([null, {}, { ...wireDetail, id: "" }, { ...wireDetail, id: 42 }])( + "an incomplete create response is an unknown outcome: %j", + async (body) => { + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify(body), { status: 200 })); + await expect(create({ API_KEY: "provider-key" })).resolves.toMatchObject({ + code: "secret_process_outcome_unknown", + retryable: false, + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + } + ); +}); + +describe("processes with secrets", () => { + test.each([null, {}, { process: null }, { process: { id: " " } }, { process: { id: 42 } }])( + "an incomplete process response preserves the unknown outcome: %j", + async (response) => { + const start = starter(async () => response as any); + const api = new SandboxProcessesApi(runtime({}).transport, start); + await expect(api.start("x", { secrets: { A: "s" } })).rejects.toMatchObject({ + code: "secret_process_outcome_unknown", + retryable: false, + details: { sandboxId: "sbx_123" }, + }); + expect(start.start).toHaveBeenCalledTimes(1); + } + ); + + test("start goes through the control API and reports the delivered versions", async () => { + const start = starter(); + const { transport, openSSE } = runtime({}); + const api = new SandboxProcessesApi(transport, start); + + const handle = await api.start("node app.js", { + cwd: "/app", + env: { NODE_ENV: "production" }, + timeoutSec: 30, + runAs: "root", + secrets: { API_KEY: { name: "provider-key", version: "latest" } }, + }); + + expect(openSSE).not.toHaveBeenCalled(); + expect(start.start).toHaveBeenCalledWith( + { + command: "node app.js", + cwd: "/app", + env: { NODE_ENV: "production" }, + timeoutMs: 30_000, + runAs: "root", + secrets: { API_KEY: { name: "provider-key", version: "latest" } }, + }, + undefined + ); + expect(handle.id).toBe("proc_1"); + expect(handle.toJSON()).toMatchObject({ pid: 42, startedAt: 10, cwd: "/app" }); + expect(handle.secrets).toEqual(resolved); + }); + + test("command timeouts are sent as whole milliseconds within the API's range", async () => { + for (const [timeouts, timeoutMs] of [ + [{ timeoutSec: 1.5 }, 1_500], + [{ timeoutSec: 1.1 }, 1_100], + [{ timeoutSec: 0.0001 }, 1], + [{ timeoutMs: 2.4 }, 2], + [{ timeoutSec: 0, timeoutMs: 250 }, 250], + [{ timeoutSec: 86_400 }, 86_400_000], + [{ timeoutSec: 0 }, undefined], + ] as const) { + const start = starter(); + const api = new SandboxProcessesApi(runtime({}).transport, start); + await api.start({ command: "x", ...timeouts, secrets: { A: "s" } }); + expect(start.start.mock.calls[0][0].timeoutMs).toBe(timeoutMs); + } + + for (const timeouts of [ + { timeoutSec: 86_400.001 }, + { timeoutMs: 86_400_001 }, + { timeoutSec: -1 }, + { timeoutMs: Number.NaN }, + { timeoutSec: Number.POSITIVE_INFINITY }, + ]) { + const start = starter(); + const api = new SandboxProcessesApi(runtime({}).transport, start); + await expect(api.start({ command: "x", ...timeouts, secrets: { A: "s" } })).rejects.toThrow( + /non-negative number|at most 86400 seconds/ + ); + expect(start.start).not.toHaveBeenCalled(); + } + }); + + test("the API's unconfirmed start becomes secret_process_outcome_unknown", async () => { + const body = { + code: "SECRET_PROCESS_OUTCOME_UNKNOWN", + message: "The sandbox host did not confirm the process start", + retryable: false, + }; + const apiError = new HyperbrowserError(body.message, { + statusCode: 502, + code: body.code, + requestId: "req_1", + retryable: false, + service: "control", + details: body, + }); + const api = new SandboxProcessesApi( + runtime({}).transport, + starter(async () => { + throw apiError; + }) + ); + + const error = await api.start({ command: "x", secrets: { A: "s" } }).catch((e) => e); + + expect(error).toMatchObject({ + statusCode: 502, + code: "secret_process_outcome_unknown", + requestId: "req_1", + retryable: false, + details: { ...body, sandboxId: "sbx_123" }, + cause: apiError, + }); + expect(error.message).toBe(`[Hyperbrowser]: ${body.message}`); + }); + + test("a variable set by both env and secrets is rejected before starting", async () => { + const start = starter(); + const api = new SandboxProcessesApi(runtime({}).transport, start); + await expect( + api.start({ command: "env", env: { API_KEY: "x" }, secrets: { API_KEY: "provider-key" } }) + ).rejects.toThrow(/both env and secrets: API_KEY/); + expect(start.start).not.toHaveBeenCalled(); + }); + + test("exec closes stdin and waits past expired waits for the final result", async () => { + const expired = new HyperbrowserError("wait timed out", { statusCode: 408 }); + const { transport, requestJSON } = runtime({ + "/sandbox/processes/proc_1/stdin": [new HyperbrowserError("exited", { statusCode: 409 })], + "/sandbox/processes/proc_1/wait": [expired, expired, { result }], + }); + const api = new SandboxProcessesApi(transport, starter()); + + const output = await api.exec("node app.js", { secrets: { API_KEY: "provider-key" } }); + + expect(output).toMatchObject({ id: "proc_1", exitCode: 0, stdout: "ok\n" }); + expect(requestJSON.mock.calls.map(([path]) => path)).toEqual([ + "/sandbox/processes/proc_1/stdin", + "/sandbox/processes/proc_1/wait", + "/sandbox/processes/proc_1/wait", + "/sandbox/processes/proc_1/wait", + ]); + expect(JSON.parse((requestJSON.mock.calls[0][1] as any).body)).toEqual({ eof: true }); + expect(JSON.parse((requestJSON.mock.calls[1][1] as any).body)).toMatchObject({ + timeoutMs: 20_000, + }); + }); + + test("a failure after the start names the running process", async () => { + const { transport } = runtime({ + "/sandbox/processes/proc_1/stdin": [{ success: true }], + "/sandbox/processes/proc_1/wait": [new HyperbrowserError("gone", { statusCode: 502 })], + }); + const api = new SandboxProcessesApi(transport, starter()); + const error = await api.exec("x", { secrets: { A: "s" } }).catch((e) => e); + expect(error).toMatchObject({ + code: "secret_process_failed", + retryable: false, + details: { sandboxId: "sbx_123", processId: "proc_1" }, + }); + expect(error.message).toContain("Process proc_1 started"); + }); + + test("an unreadable or timed-out start is an unknown outcome; API errors pass through", async () => { + const timedOut = new HyperbrowserError("network timeout", { + cause: Object.assign(new Error("timeout"), { type: "request-timeout" }), + }); + const refused = new HyperbrowserError("refused", { + cause: Object.assign(new Error("connect"), { code: "ECONNREFUSED" }), + }); + const apiError = new HyperbrowserError("denied", { + statusCode: 403, + code: "API_KEY_SCOPE_DENIED", + }); + + for (const [error, code] of [ + [timedOut, "secret_process_outcome_unknown"], + [refused, undefined], + [apiError, "API_KEY_SCOPE_DENIED"], + ] as const) { + const api = new SandboxProcessesApi( + runtime({}).transport, + starter(async () => { + throw error; + }) + ); + await expect(api.start({ command: "x", secrets: { A: "s" } })).rejects.toMatchObject({ + code, + }); + } + + const missingId = new SandboxProcessesApi( + runtime({}).transport, + starter(async () => ({ secrets: resolved })) + ); + await expect(missingId.start({ command: "x", secrets: { A: "s" } })).rejects.toMatchObject({ + code: "secret_process_outcome_unknown", + }); + }); + + test("sandbox handles start secret processes on their own sandbox", async () => { + const service = new SandboxesService("test-key", "https://api.example.com", 30_000); + const requestSpy = vi + .spyOn(service as any, "request") + .mockResolvedValueOnce(wireDetail) + .mockResolvedValueOnce({ process: rawProcess, secrets: resolved }); + const sandbox = await service.get("sbx_123"); + + const handle = await sandbox.processes.start({ + command: "node app.js", + secrets: { API_KEY: "provider-key" }, + }); + + expect(handle.secrets).toEqual(resolved); + expect(requestSpy.mock.calls[1][0]).toBe("/sandbox/sbx_123/processes"); + expect(JSON.parse((requestSpy.mock.calls[1][1] as any).body)).toEqual({ + command: "node app.js", + secrets: { API_KEY: { name: "provider-key" } }, + }); + }); +}); + +describe("control API error fields", () => { + class TestService extends BaseService { + post(path: string) { + return this.request(path, { method: "POST", body: "{}" }); + } + } + + const respond = (status: number, body: unknown) => + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify(body), { + status, + headers: new Headers({ "content-type": "application/json" }), + }) + ); + + afterEach(() => fetchMock.mockReset()); + + test("the server's retryable flag wins over the status code", async () => { + respond(502, { code: "SECRET_PROCESS_OUTCOME_UNKNOWN", message: "unknown", retryable: false }); + const service = new TestService("key", "https://api.example.com", 1_000); + await expect(service.post("/x")).rejects.toMatchObject({ + statusCode: 502, + code: "SECRET_PROCESS_OUTCOME_UNKNOWN", + retryable: false, + }); + }); + + test("a machine code sent as error next to a message becomes the code", async () => { + respond(403, { + error: "API_KEY_SCOPE_DENIED", + message: "denied", + requiredScopes: ["secrets.use"], + }); + const service = new TestService("key", "https://api.example.com", 1_000); + const error = (await service.post("/x").catch((e: unknown) => e)) as HyperbrowserError; + expect(error).toMatchObject({ code: "API_KEY_SCOPE_DENIED", statusCode: 403 }); + expect(error.message).toContain("denied"); + expect(error.details).toMatchObject({ requiredScopes: ["secrets.use"] }); + }); + + test("SECRETS_NOT_CONFIGURED is not retried despite its 503", async () => { + respond(503, { code: "SECRETS_NOT_CONFIGURED", message: "not configured" }); + const service = new TestService("key", "https://api.example.com", 1_000); + await expect(service.post("/x")).rejects.toMatchObject({ + statusCode: 503, + code: "SECRETS_NOT_CONFIGURED", + retryable: false, + }); + + class GetService extends BaseService { + get(path: string) { + return this.request(path); + } + } + respond(503, { code: "SECRETS_NOT_CONFIGURED", message: "not configured" }); + await expect( + new GetService("key", "https://api.example.com", 1_000).get("/secrets/public-key") + ).rejects.toMatchObject({ retryable: false }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + test("messages name the sandbox and the secret an error is about", async () => { + const service = new TestService("key", "https://api.example.com", 1_000); + respond(500, { + code: "SECRET_STARTUP_FAILED", + message: "Startup failed", + sandboxId: "sbx_9", + details: { env: "API_KEY", secretId: "secret_1", version: 3 }, + }); + const error = (await service.post("/x").catch((e: unknown) => e)) as HyperbrowserError; + expect(error.message).toBe( + "[Hyperbrowser]: Startup failed (sandbox sbx_9) (API_KEY: secret secret_1, version 3)" + ); + expect(error.details).toMatchObject({ sandboxId: "sbx_9" }); + + respond(400, { error: "Bad secret", details: { env: "DB_URL" } }); + await expect(service.post("/x")).rejects.toThrow(/^\[Hyperbrowser\]: Bad secret \(DB_URL\)$/); + + respond(400, { message: "Invalid", details: { reason: "other shape" } }); + await expect(service.post("/x")).rejects.toThrow(/^\[Hyperbrowser\]: Invalid$/); + + respond(409, { sandboxId: "sbx_9" }); + await expect(service.post("/x")).rejects.toThrow(/^\[Hyperbrowser\]: HTTP error! status: 409$/); + }); + + test("a plain error message is not taken as a code", async () => { + respond(400, { error: "Invalid body" }); + const service = new TestService("key", "https://api.example.com", 1_000); + await expect(service.post("/x")).rejects.toMatchObject({ code: undefined, retryable: false }); + }); +}); diff --git a/tests/unit/secrets-contract.test.ts b/tests/unit/secrets-contract.test.ts new file mode 100644 index 0000000..fc83834 --- /dev/null +++ b/tests/unit/secrets-contract.test.ts @@ -0,0 +1,286 @@ +import crypto from "crypto"; +import { describe, expect, test, vi } from "vitest"; +import { HyperbrowserError } from "../../src/client"; +import { SecretsSealer } from "../../src/secrets-envelope"; +import { SecretsService } from "../../src/services/secrets"; +import type { SecretsPublicKey } from "../../src/types"; + +const ID = "6f1d3c1e-5b8a-4e6f-9a51-0c2b7f3d9e10"; +const UUID_NAME = "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d"; + +const keyPair = (bits: number, publicExponent = 65537) => + crypto.generateKeyPairSync("rsa", { modulusLength: bits, publicExponent }); + +const publicKeyFor = (publicKey: crypto.KeyObject): SecretsPublicKey => { + const jwk = publicKey.export({ format: "jwk" }) as { n: string; e: string }; + const kid = crypto + .createHash("sha256") + .update(`{"e":"${jwk.e}","kty":"RSA","n":"${jwk.n}"}`) + .digest("base64url"); + return { kid, alg: "RSA-OAEP-256", enc: "A256GCM", jwk: { kty: "RSA", n: jwk.n, e: jwk.e } }; +}; + +const platformKey = keyPair(3072); +const platformPublicKey = publicKeyFor(platformKey.publicKey); + +const open = (envelope: string) => { + const [protectedHeader, encryptedKey, iv, ciphertext, tag] = envelope.split("."); + const cek = crypto.privateDecrypt( + { + key: platformKey.privateKey, + padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, + oaepHash: "sha256", + }, + Buffer.from(encryptedKey, "base64url") + ); + const decipher = crypto.createDecipheriv("aes-256-gcm", cek, Buffer.from(iv, "base64url")); + decipher.setAAD(Buffer.from(protectedHeader, "ascii")); + decipher.setAuthTag(Buffer.from(tag, "base64url")); + return { + header: JSON.parse(Buffer.from(protectedHeader, "base64url").toString()), + value: Buffer.concat([decipher.update(Buffer.from(ciphertext, "base64url")), decipher.final()]), + }; +}; + +const secret = (overrides: Record = {}) => ({ + id: ID, + name: "provider-key", + description: "", + encryptionContext: `hyperbrowser:secret:v1:team:${ID}`, + latestVersion: null, + createdAt: "2026-10-01T00:00:00.000Z", + updatedAt: "2026-10-01T00:00:00.000Z", + ...overrides, +}); + +const notFound = () => + new HyperbrowserError("Secret not found", { statusCode: 404, code: "SECRET_NOT_FOUND" }); + +/** Routes `request(path, init, params)` calls to `handler(method, path, body, params)`. */ +const mockService = ( + handler: (method: string, path: string, body: any, params: any) => unknown +) => { + const service = new SecretsService("test-key", "https://api.example.com", 30_000); + const requestSpy = vi + .spyOn(service as any, "request") + .mockImplementation(async (path: any, init: any, params: any) => { + const result = handler( + init?.method ?? "GET", + path, + init?.body ? JSON.parse(init.body) : undefined, + params + ); + if (result instanceof Error) throw result; + return result; + }); + const calls = () => + requestSpy.mock.calls.map(([path, init]: any[]) => `${init?.method ?? "GET"} ${path}`); + return { service, requestSpy, calls }; +}; + +describe("secret envelopes", () => { + test("seal produces a compact JWE the platform key can open", () => { + const sealer = SecretsSealer.fromPublicKey(platformPublicKey); + const envelope = sealer.seal("ctx-1", "value with newline\n"); + expect(envelope.split(".")).toHaveLength(5); + const { header, value } = open(envelope); + expect(header).toEqual({ + alg: "RSA-OAEP-256", + enc: "A256GCM", + kid: platformPublicKey.kid, + ctx: "ctx-1", + }); + expect(value.toString()).toBe("value with newline\n"); + }); + + test("bytes are sealed byte-for-byte", () => { + const sealer = SecretsSealer.fromPublicKey(platformPublicKey); + const bytes = new Uint8Array([0x68, 0xc3, 0xa9, 0x0a]); + expect(open(sealer.seal("ctx", bytes)).value).toEqual(Buffer.from(bytes)); + }); + + test("keys whose ID does not match are refused", () => { + expect(() => + SecretsSealer.fromPublicKey({ ...platformPublicKey, kid: "not-the-thumbprint" }) + ).toThrow(/does not match its key ID/); + }); + + test("keys outside the platform policy are refused", () => { + expect(() => SecretsSealer.fromPublicKey(publicKeyFor(keyPair(2048).publicKey))).toThrow( + /at least 3072-bit/ + ); + expect(() => SecretsSealer.fromPublicKey(publicKeyFor(keyPair(3072, 3).publicKey))).toThrow( + /exponent 65537/ + ); + expect(() => SecretsSealer.fromPublicKey({ ...platformPublicKey, alg: "RSA1_5" })).toThrow( + /Unsupported/ + ); + }); + + test.each([ + ["empty", ""], + ["too large", "x".repeat(32 * 1024 + 1)], + ["NUL", "a\0b"], + ["invalid UTF-8", new Uint8Array([0xff, 0xfe])], + ])("value rules: %s", (_name, value) => { + const sealer = SecretsSealer.fromPublicKey(platformPublicKey); + expect(() => sealer.seal("ctx", value)).toThrow(HyperbrowserError); + }); + + test("the largest allowed value is accepted", () => { + const sealer = SecretsSealer.fromPublicKey(platformPublicKey); + expect(open(sealer.seal("ctx", "x".repeat(32 * 1024))).value).toHaveLength(32 * 1024); + }); +}); + +describe("secrets service", () => { + test("create with a value checks the key first, then uploads only an envelope", async () => { + const { service, calls, requestSpy } = mockService((method, path) => { + if (path === "/secrets/public-key") return platformPublicKey; + if (method === "POST" && path === "/secrets") return secret(); + if (method === "POST" && path === `/secrets/${ID}/versions`) + return { version: 1, kid: platformPublicKey.kid, createdAt: "2026-10-01T00:00:00.000Z" }; + throw new Error(`unexpected ${method} ${path}`); + }); + + const created = await service.create({ name: "provider-key", value: "s3cret" }); + + expect(calls()).toEqual([ + "GET /secrets/public-key", + "POST /secrets", + `POST /secrets/${ID}/versions`, + ]); + expect(JSON.parse((requestSpy.mock.calls[1][1] as any).body)).toEqual({ name: "provider-key" }); + const upload = JSON.parse((requestSpy.mock.calls[2][1] as any).body); + expect(Object.keys(upload)).toEqual(["envelope"]); + expect(upload.envelope).not.toContain("s3cret"); + const { header, value } = open(upload.envelope); + expect(header.ctx).toBe(secret().encryptionContext); + expect(value.toString()).toBe("s3cret"); + expect(created.latestVersion).toBe(1); + }); + + test("an invalid value or an unusable key never creates a secret", async () => { + const { service, calls } = mockService((_method, path) => { + if (path === "/secrets/public-key") return { ...platformPublicKey, kid: "wrong" }; + return secret(); + }); + await expect(service.create({ name: "provider-key", value: "" })).rejects.toThrow(/empty/); + await expect(service.create({ name: "provider-key", value: "ok" })).rejects.toThrow(/key ID/); + expect(calls()).toEqual(["GET /secrets/public-key"]); + }); + + test("invalid names, list pages and version numbers are rejected locally", async () => { + const { service, calls } = mockService(() => secret()); + for (const name of ["", "-leading-dash", "has space", "x".repeat(129)]) { + await expect(service.create({ name })).rejects.toThrow(/Secret name must be/); + } + await expect(service.list({ limit: 0 })).rejects.toThrow( + /limit must be an integer from 1 to 100/ + ); + await expect(service.list({ limit: 101 })).rejects.toThrow(/limit must be/); + await expect(service.list({ limit: 1.5 })).rejects.toThrow(/limit must be/); + await expect(service.list({ page: 0 })).rejects.toThrow(/page must be a positive integer/); + await expect(service.deleteVersion("provider-key", 2 ** 31)).rejects.toThrow( + /version must be an integer from 1 to 2147483647/ + ); + expect(calls()).toEqual([]); + }); + + test("a failed upload after creating reports the partial create", async () => { + const { service } = mockService((method, path) => { + if (path === "/secrets/public-key") return platformPublicKey; + if (method === "POST" && path === "/secrets") return secret(); + return new HyperbrowserError("key outdated", { + statusCode: 409, + code: "SECRET_KEY_OUTDATED", + }); + }); + const error = await service.create({ name: "provider-key", value: "v" }).catch((e) => e); + expect(error).toBeInstanceOf(HyperbrowserError); + expect(error.code).toBe("secret_value_not_stored"); + expect(error.retryable).toBe(false); + expect(error.message).toContain(`provider-key (${ID}) was created`); + expect(error.message).toContain("key outdated"); + expect(error.details).toEqual({ secret: secret() }); + }); + + test("an ID resolves without a name search", async () => { + const detail = { ...secret(), versions: [] }; + const { service, calls } = mockService(() => detail); + await expect(service.get(ID)).resolves.toEqual(detail); + expect(calls()).toEqual([`GET /secrets/${ID}`]); + }); + + test("a UUID-shaped name falls back to a name search after a 404", async () => { + const named = secret({ id: ID, name: UUID_NAME }); + const { service, calls, requestSpy } = mockService((_method, path) => { + if (path === `/secrets/${UUID_NAME}`) return notFound(); + if (path === "/secrets") return { secrets: [named], totalCount: 1, page: 1, perPage: 100 }; + return { ...named, versions: [] }; + }); + await expect(service.get(UUID_NAME)).resolves.toMatchObject({ name: UUID_NAME }); + expect(calls()).toEqual([`GET /secrets/${UUID_NAME}`, "GET /secrets", `GET /secrets/${ID}`]); + expect(requestSpy.mock.calls[1][2]).toEqual({ search: UUID_NAME, page: undefined, limit: 100 }); + }); + + test("other ID lookup errors are reported without a name search", async () => { + const { service, calls } = mockService( + () => new HyperbrowserError("boom", { statusCode: 500 }) + ); + await expect(service.get(ID)).rejects.toMatchObject({ statusCode: 500 }); + expect(calls()).toEqual([`GET /secrets/${ID}`]); + }); + + test("names match exactly, not by prefix", async () => { + const { service } = mockService(() => ({ + secrets: [secret({ name: "provider-key-old" })], + totalCount: 1, + page: 1, + perPage: 100, + })); + await expect(service.delete("provider-key")).rejects.toMatchObject({ + statusCode: 404, + code: "SECRET_NOT_FOUND", + }); + }); + + test("addVersion, update, delete and deleteVersion use the resolved ID", async () => { + const { service, calls, requestSpy } = mockService((method, path) => { + if (path === "/secrets") return { secrets: [secret()], totalCount: 1, page: 1, perPage: 100 }; + if (path === "/secrets/public-key") return platformPublicKey; + if (method === "POST") return { version: 2, kid: platformPublicKey.kid, createdAt: "" }; + if (method === "PATCH") return secret({ description: "rotated monthly" }); + if (path.includes("/versions/")) return { deleted: true, version: 1 }; + return { deleted: true, id: ID, name: "provider-key" }; + }); + + await expect(service.addVersion("provider-key", "next")).resolves.toMatchObject({ version: 2 }); + await expect( + service.update("provider-key", { description: "rotated monthly" }) + ).resolves.toMatchObject({ description: "rotated monthly" }); + await expect(service.deleteVersion("provider-key", 1)).resolves.toEqual({ + deleted: true, + version: 1, + }); + await expect(service.delete("provider-key")).resolves.toEqual({ + deleted: true, + id: ID, + name: "provider-key", + }); + expect(calls()).toEqual([ + "GET /secrets/public-key", + "GET /secrets", + `POST /secrets/${ID}/versions`, + "GET /secrets", + `PATCH /secrets/${ID}`, + "GET /secrets", + `DELETE /secrets/${ID}/versions/1`, + "GET /secrets", + `DELETE /secrets/${ID}`, + ]); + expect(JSON.parse((requestSpy.mock.calls[4][1] as any).body)).toEqual({ + description: "rotated monthly", + }); + }); +});